From da03e328c2ff8a618e4a432ad9a1892b43578563 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 26 Mar 2026 09:10:53 -0700 Subject: [PATCH 1/5] Fix async patterns: sync-over-async, CancellationToken, empty catches, fire-and-forget, ConfigureAwait(false) Comprehensive fix for async anti-patterns across all library projects: - #396: Replace sync-over-async GetAwaiter().GetResult() with proper await in ApplicationBuilder.Functions, Stream, ChatPrompt.Errors, ServiceCollection - #397: Propagate CancellationToken correctly in OpenAIChatModel.Send, Context.Send, HttpClient; add CancellationToken to OnBuildInstructions - #398: Replace empty catch blocks with exception logging in App.cs, Context.SignIn, McpClientPlugin - #399: Add error handling to fire-and-forget Flush() in AspNetCorePlugin.Stream, replace Task.Run(() => {}) with Task.CompletedTask, use _ = discard with ConfigureAwait in debounce extensions - #400: Add ConfigureAwait(false) to ~380 await calls across all library projects - #401: Replace Task.Run wrapping sync code with direct execution in LocalStorage, fix unsafe reflection-based Task.Result access in MethodInfoExtensions Fixes #396, #397, #398, #399, #400, #401 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../OpenAIChatModel.Send.cs | 22 +++++----- .../Microsoft.Teams.AI/BaseChatPlugin.cs | 2 +- Libraries/Microsoft.Teams.AI/ChatPlugin.cs | 2 +- .../Prompts/ChatPrompt/ChatPrompt.Chain.cs | 4 +- .../Prompts/ChatPrompt/ChatPrompt.Errors.cs | 2 +- .../ChatPrompt/ChatPrompt.Functions.cs | 10 ++--- .../Prompts/ChatPrompt/ChatPrompt.Send.cs | 24 +++++------ Libraries/Microsoft.Teams.AI/Stream.cs | 13 +++++- .../Auth/ClientCredentials.cs | 4 +- .../Auth/TokenCredentials.cs | 4 +- .../Clients/ActivityClient.cs | 16 ++++---- .../Clients/BotSignInClient.cs | 6 +-- .../Clients/BotTokenClient.cs | 6 +-- .../Clients/ConversationClient.cs | 4 +- .../Clients/MeetingClient.cs | 6 +-- .../Clients/MemberClient.cs | 8 ++-- .../Clients/ReactionClient.cs | 4 +- .../Microsoft.Teams.Api/Clients/TeamClient.cs | 6 +-- .../Clients/UserTokenClient.cs | 10 ++--- .../Plugins/TestPlugin.cs | 10 ++--- .../Activities/Activity.cs | 18 ++++---- .../Activities/CommandActivity.cs | 4 +- .../Activities/CommandResultActivity.cs | 4 +- .../Conversations/ChannelCreatedActivity.cs | 4 +- .../Conversations/ChannelDeletedActivity.cs | 4 +- .../ChannelMemberAddedActivity.cs | 4 +- .../ChannelMemberRemovedActivity.cs | 4 +- .../Conversations/ChannelRenamedActivity.cs | 4 +- .../Conversations/ChannelRestoredActivity.cs | 4 +- .../Conversations/ChannelSharedActivity.cs | 4 +- .../Conversations/ChannelUnsharedActivity.cs | 4 +- .../Conversations/ConversationEndActivity.cs | 4 +- .../ConversationUpdateActivity.cs | 4 +- .../Conversations/MembersAddedActivity.cs | 4 +- .../Conversations/MembersRemovedActivity.cs | 4 +- .../Conversations/TeamArchivedActivity.cs | 4 +- .../Conversations/TeamDeletedActivity.cs | 8 ++-- .../Conversations/TeamRenamedActivity.cs | 4 +- .../Conversations/TeamRestoredActivity.cs | 4 +- .../Conversations/TeamUnArchivedActivity.cs | 4 +- .../Activities/Events/EventActivity.cs | 4 +- .../Activities/Events/MeetingEndActivity.cs | 4 +- .../Activities/Events/MeetingJoinActivity.cs | 4 +- .../Activities/Events/MeetingLeaveActivity.cs | 4 +- .../Activities/Events/MeetingStartActivity.cs | 4 +- .../Activities/Events/ReadReceiptActivity.cs | 4 +- .../Activities/Installs/InstallActivity.cs | 4 +- .../Installs/InstallUpdateActivity.cs | 4 +- .../Activities/Installs/UnInstallActivity.cs | 4 +- .../Invokes/AdaptiveCards/ActionActivity.cs | 12 +++--- .../Invokes/Configs/FetchActivity.cs | 12 +++--- .../Invokes/Configs/SubmitActivity.cs | 12 +++--- .../Invokes/ExecuteActionActivity.cs | 4 +- .../Activities/Invokes/FileConsentActivity.cs | 4 +- .../Activities/Invokes/HandoffActivity.cs | 4 +- .../Activities/Invokes/InvokeActivity.cs | 8 ++-- .../AnonQueryLinkActivity.cs | 12 +++--- .../CardButtonClickedActivity.cs | 4 +- .../MessageExtensions/FetchTaskActivity.cs | 12 +++--- .../MessageExtensions/QueryActivity.cs | 12 +++--- .../MessageExtensions/QueryLinkActivity.cs | 12 +++--- .../QuerySettingsUrlActivity.cs | 12 +++--- .../MessageExtensions/SelectItemActivity.cs | 12 +++--- .../MessageExtensions/SettingsActivity.cs | 4 +- .../MessageExtensions/SubmitActionActivity.cs | 12 +++--- .../Invokes/Messages/FeedbackActivity.cs | 4 +- .../Invokes/Messages/SubmitActionActivity.cs | 4 +- .../Invokes/Search/AnswerSearchActivity.cs | 12 +++--- .../Invokes/Search/SearchActivity.cs | 12 +++--- .../Invokes/Search/TypeaheadSearchActivity.cs | 12 +++--- .../Invokes/SignIn/FailureActivity.cs | 8 ++-- .../Invokes/SignIn/TokenExchangeActivity.cs | 16 ++++---- .../Invokes/SignIn/VerifyStateAcitivity.cs | 8 ++-- .../Activities/Invokes/Tabs/FetchActivity.cs | 12 +++--- .../Activities/Invokes/Tabs/SubmitActivity.cs | 12 +++--- .../Activities/Invokes/Tasks/FetchActivity.cs | 12 +++--- .../Invokes/Tasks/SubmitActivity.cs | 12 +++--- .../Activities/Messages/MessageActivity.cs | 12 +++--- .../Messages/MessageDeleteActivity.cs | 4 +- .../Messages/MessageReactionActivity.cs | 12 +++--- .../Messages/MessageUpdateActivity.cs | 4 +- .../Activities/TypingActivity.cs | 4 +- Libraries/Microsoft.Teams.Apps/App.cs | 41 ++++++++++--------- Libraries/Microsoft.Teams.Apps/AppEvents.cs | 8 ++-- Libraries/Microsoft.Teams.Apps/AppPlugins.cs | 4 +- Libraries/Microsoft.Teams.Apps/AppRouting.cs | 18 ++++---- .../Contexts/Client/FunctionContext.cs | 4 +- .../Contexts/Context.Send.cs | 4 +- .../Contexts/Context.SignIn.cs | 31 +++++++------- .../Microsoft.Teams.Apps/Contexts/Context.cs | 2 +- .../Events/EventEmitter.cs | 6 +-- .../Microsoft.Teams.Apps/Events/Topic.cs | 2 +- .../Microsoft.Teams.Apps/Routing/Route.cs | 2 +- .../Extensions/ActionExtensions.cs | 14 +++---- .../Extensions/MethodInfoExtensions.cs | 7 +++- .../Extensions/TaskExtensions.cs | 4 +- .../Microsoft.Teams.Common/Http/HttpClient.cs | 18 ++++---- .../Storage/LocalStorage.cs | 10 +++-- .../ServiceCollection.cs | 3 +- .../TeamsService.cs | 4 +- .../Controllers/MessageController.cs | 4 +- .../Controllers/ActivityController.cs | 4 +- .../Controllers/DevToolsController.cs | 10 ++--- .../DevToolsPlugin.cs | 8 ++-- .../WebSocketCollection.cs | 6 +-- .../AspNetCorePlugin.Stream.cs | 29 +++++++++---- .../AspNetCorePlugin.cs | 24 +++++------ .../ApplicationBuilder.Functions.cs | 22 +++++----- .../Extensions/ApplicationBuilder.Tabs.cs | 6 +-- .../Extensions/ApplicationBuilder.cs | 4 +- .../Extensions/McpServerBuilder.cs | 4 +- .../McpClientPlugin.cs | 22 +++++----- .../ChatPluginTests.cs | 2 +- .../Utils/TestChatPlugin.cs | 2 +- 114 files changed, 486 insertions(+), 448 deletions(-) diff --git a/Libraries/Microsoft.Teams.AI.Models.OpenAI/OpenAIChatModel.Send.cs b/Libraries/Microsoft.Teams.AI.Models.OpenAI/OpenAIChatModel.Send.cs index fdbc1b31d..e7b8b2512 100644 --- a/Libraries/Microsoft.Teams.AI.Models.OpenAI/OpenAIChatModel.Send.cs +++ b/Libraries/Microsoft.Teams.AI.Models.OpenAI/OpenAIChatModel.Send.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Text; @@ -19,14 +19,14 @@ public async Task Send(IMessage message, ChatCompletionOptions? option { Functions = [], Messages = [] - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); return res; } public async Task> Send(IMessage message, ChatModelOptions options, CancellationToken cancellationToken = default) { - var messages = await CallFunctions(message, options, cancellationToken); + var messages = await CallFunctions(message, options, cancellationToken).ConfigureAwait(false); var chatMessages = messages.Select(m => m.ToOpenAI()).ToList(); if (options.Prompt is not null) @@ -48,14 +48,14 @@ public async Task> Send(IMessage message, ChatModelOptions< var result = await ChatClient.CompleteChatAsync( chatMessages, requestOptions, - CancellationToken.None - ); + cancellationToken + ).ConfigureAwait(false); var modelMessage = ChatMessage.CreateAssistantMessage(result.Value).ToTeams(); if (modelMessage.HasFunctionCalls) { - return await Send(modelMessage, options, cancellationToken); + return await Send(modelMessage, options, cancellationToken).ConfigureAwait(false); } messages.Add(modelMessage); @@ -70,7 +70,7 @@ public async Task> Send(IMessage message, ChatModelOptions< public async Task> Send(IMessage message, ChatModelOptions options, IStream stream, CancellationToken cancellationToken = default) { - var messages = await CallFunctions(message, options, cancellationToken); + var messages = await CallFunctions(message, options, cancellationToken).ConfigureAwait(false); var chatMessages = messages.Select(m => m.ToOpenAI()).ToList(); if (options.Prompt is not null) @@ -89,7 +89,7 @@ public async Task> Send(IMessage message, ChatModelOptions< requestOptions.Tools.Add(tool); } - var res = ChatClient.CompleteChatStreamingAsync(chatMessages, requestOptions, CancellationToken.None); + var res = ChatClient.CompleteChatStreamingAsync(chatMessages, requestOptions, cancellationToken); var content = new StringBuilder(); var toolCalls = new StreamingChatToolCallsBuilder(); @@ -108,12 +108,12 @@ public async Task> Send(IMessage message, ChatModelOptions< } content.Append(delta); - stream.Emit(delta.ToString()); + await stream.EmitAsync(delta.ToString()).ConfigureAwait(false); if (chunk.FinishReason == ChatFinishReason.ToolCalls) { var input = ChatMessage.CreateAssistantMessage(toolCalls.Build()).ToTeams(); - return await Send(input, options, stream, cancellationToken); + return await Send(input, options, stream, cancellationToken).ConfigureAwait(false); } else if (chunk.FinishReason == ChatFinishReason.Length) { @@ -152,7 +152,7 @@ protected async Task> CallFunctions(IMessage message, ChatModelO try { var args = call.Parse() ?? new Dictionary(); - var res = await options.Invoke(call, cancellationToken); + var res = await options.Invoke(call, cancellationToken).ConfigureAwait(false); content = res is string asString ? asString : JsonSerializer.Serialize(res); logger.Debug(content); diff --git a/Libraries/Microsoft.Teams.AI/BaseChatPlugin.cs b/Libraries/Microsoft.Teams.AI/BaseChatPlugin.cs index c076e2872..4a775630b 100644 --- a/Libraries/Microsoft.Teams.AI/BaseChatPlugin.cs +++ b/Libraries/Microsoft.Teams.AI/BaseChatPlugin.cs @@ -36,7 +36,7 @@ public virtual Task OnBuildFunctions(IChatPrompt OnBuildInstructions(IChatPrompt prompt, DeveloperMessage? instructions) + public virtual Task OnBuildInstructions(IChatPrompt prompt, DeveloperMessage? instructions, CancellationToken cancellationToken = default) { return Task.FromResult(instructions); } diff --git a/Libraries/Microsoft.Teams.AI/ChatPlugin.cs b/Libraries/Microsoft.Teams.AI/ChatPlugin.cs index 8e981fa6d..12a6ed606 100644 --- a/Libraries/Microsoft.Teams.AI/ChatPlugin.cs +++ b/Libraries/Microsoft.Teams.AI/ChatPlugin.cs @@ -66,5 +66,5 @@ public interface IChatPlugin /// the prompt /// the instructions /// the transformed instructions - public Task OnBuildInstructions(IChatPrompt prompt, DeveloperMessage? instructions); + public Task OnBuildInstructions(IChatPrompt prompt, DeveloperMessage? instructions, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Chain.cs b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Chain.cs index 128b8f74c..5bdbfe569 100644 --- a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Chain.cs +++ b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Chain.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Json.Schema; @@ -22,7 +22,7 @@ public ChatPrompt Chain(IChatPrompt prompt) ), async (string text) => { - var res = await prompt.Send(text); + var res = await prompt.Send(text).ConfigureAwait(false); return res.Content; } )); diff --git a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs index 60fc2fb99..e4db2e3f5 100644 --- a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs +++ b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs @@ -13,7 +13,7 @@ public IChatPrompt OnError(Action onError) public IChatPrompt OnError(Func onError) { - ErrorEvent += (_, ex) => onError(ex).GetAwaiter().GetResult(); + ErrorEvent += async (_, ex) => await onError(ex).ConfigureAwait(false); return this; } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Functions.cs b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Functions.cs index 357933704..d501e36f8 100644 --- a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Functions.cs +++ b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Functions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Humanizer; @@ -36,7 +36,7 @@ public ChatPrompt Function(string name, string? description, JsonSchem public Func> Invoke(FunctionCollection functions) { - return async (call, cancellationToken) => await _Invoke(call, functions, cancellationToken); + return async (call, cancellationToken) => await _Invoke(call, functions, cancellationToken).ConfigureAwait(false); } private async Task _Invoke(FunctionCall call, FunctionCollection functions, CancellationToken cancellationToken = default) @@ -48,13 +48,13 @@ public ChatPrompt Function(string name, string? description, JsonSchem { foreach (var plugin in Plugins) { - call = await plugin.OnBeforeFunctionCall(this, func, call, cancellationToken); + call = await plugin.OnBeforeFunctionCall(this, func, call, cancellationToken).ConfigureAwait(false); } var startedAt = DateTime.Now; logger.Debug(call.Arguments); - var res = await func.Invoke(call); + var res = await func.Invoke(call).ConfigureAwait(false); var endedAt = DateTime.Now; logger.Debug(res); @@ -62,7 +62,7 @@ public ChatPrompt Function(string name, string? description, JsonSchem foreach (var plugin in Plugins) { - res = await plugin.OnAfterFunctionCall(this, func, call, res, cancellationToken); + res = await plugin.OnAfterFunctionCall(this, func, call, res, cancellationToken).ConfigureAwait(false); } return res; diff --git a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Send.cs b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Send.cs index 90075037a..34c183bab 100644 --- a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Send.cs +++ b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Send.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.AI.Messages; @@ -10,17 +10,17 @@ public partial class ChatPrompt { public async Task Send(IMessage message, CancellationToken cancellationToken = default) { - return await Send(message, null, null, cancellationToken); + return await Send(message, null, null, cancellationToken).ConfigureAwait(false); } public async Task> Send(string text, CancellationToken cancellationToken = default) { - return await Send(text, null, null, cancellationToken); + return await Send(text, null, null, cancellationToken).ConfigureAwait(false); } public async Task> Send(string text, OnStreamChunk? onChunk, CancellationToken cancellationToken = default) { - return await Send(text, null, onChunk, cancellationToken); + return await Send(text, null, onChunk, cancellationToken).ConfigureAwait(false); } public Task> Send(string text, IChatPrompt.RequestOptions? options, OnStreamChunk? onChunk = null, CancellationToken cancellationToken = default) @@ -49,7 +49,7 @@ public async Task> Send(IMessage message, IChatPrompt requestOptions = new(Invoke(functions)) @@ -94,23 +94,23 @@ async Task OnChunk(string chunk) foreach (var plugin in Plugins) { - message = await plugin.OnBeforeSend(this, message, requestOptions.Options, cancellationToken); + message = await plugin.OnBeforeSend(this, message, requestOptions.Options, cancellationToken).ConfigureAwait(false); } if (onChunk is null) { - res = await Model.Send(message, requestOptions, cancellationToken); + res = await Model.Send(message, requestOptions, cancellationToken).ConfigureAwait(false); } else { - res = await Model.Send(message, requestOptions, new Stream(OnChunk), cancellationToken); + res = await Model.Send(message, requestOptions, new Stream(OnChunk), cancellationToken).ConfigureAwait(false); } Logger.Debug(res); foreach (var plugin in Plugins) { - res = (ModelMessage)await plugin.OnAfterSend(this, res, requestOptions.Options, cancellationToken); + res = (ModelMessage)await plugin.OnAfterSend(this, res, requestOptions.Options, cancellationToken).ConfigureAwait(false); } return res; diff --git a/Libraries/Microsoft.Teams.AI/Stream.cs b/Libraries/Microsoft.Teams.AI/Stream.cs index d44a61aff..4e198fa02 100644 --- a/Libraries/Microsoft.Teams.AI/Stream.cs +++ b/Libraries/Microsoft.Teams.AI/Stream.cs @@ -19,6 +19,12 @@ public interface IStream /// /// the text chunk public void Emit(string text); + + /// + /// emit a text chunk asynchronously + /// + /// the text chunk + public Task EmitAsync(string text); } /// @@ -28,6 +34,11 @@ public class Stream(OnStreamChunk onChunk) : IStream { public void Emit(string text) { - onChunk(text).GetAwaiter().GetResult(); + EmitAsync(text).ConfigureAwait(false).GetAwaiter().GetResult(); + } + + public Task EmitAsync(string text) + { + return onChunk(text); } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs index 7ae6d974c..b0937a57d 100644 --- a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs +++ b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Common.Http; @@ -40,7 +40,7 @@ public async Task Resolve(IHttpClient client, string[] scopes, C { "scope", string.Join(",", scopes) } }; - var res = await client.SendAsync(request, cancellationToken); + var res = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); return res.Body; } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Auth/TokenCredentials.cs b/Libraries/Microsoft.Teams.Api/Auth/TokenCredentials.cs index 238e6f0ac..3df7a9a15 100644 --- a/Libraries/Microsoft.Teams.Api/Auth/TokenCredentials.cs +++ b/Libraries/Microsoft.Teams.Api/Auth/TokenCredentials.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Common.Http; @@ -28,6 +28,6 @@ public TokenCredentials(string clientId, string tenantId, TokenFactory token) public async Task Resolve(IHttpClient _client, string[] scopes, CancellationToken cancellationToken = default) { - return await Token(TenantId, scopes); + return await Token(TenantId, scopes).ConfigureAwait(false); } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs index ba9373dc7..4c3893d0a 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; @@ -40,7 +40,7 @@ public ActivityClient(string serviceUrl, IHttpClientFactory factory, Cancellatio body: activity ); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); if (res.Body == string.Empty) return null; @@ -55,7 +55,7 @@ public ActivityClient(string serviceUrl, IHttpClientFactory factory, Cancellatio body: activity ); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); if (res.Body == string.Empty) return null; @@ -71,7 +71,7 @@ public ActivityClient(string serviceUrl, IHttpClientFactory factory, Cancellatio body: activity ); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); if (res.Body == string.Empty) return null; @@ -85,7 +85,7 @@ public async Task DeleteAsync(string conversationId, string id) $"{ServiceUrl}v3/conversations/{conversationId}/activities/{id}" ); - await _http.SendAsync(req, _cancellationToken); + await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); } /// @@ -103,7 +103,7 @@ public async Task DeleteAsync(string conversationId, string id) body: activity ); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); if (res.Body == string.Empty) return null; @@ -126,7 +126,7 @@ public async Task DeleteAsync(string conversationId, string id) body: activity ); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); if (res.Body == string.Empty) return null; @@ -146,6 +146,6 @@ public async Task DeleteTargetedAsync(string conversationId, string id) $"{ServiceUrl}v3/conversations/{conversationId}/activities/{id}?isTargetedActivity=true" ); - await _http.SendAsync(req, _cancellationToken); + await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs index 522173617..47032afbb 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Common.Http; @@ -34,7 +34,7 @@ public async Task GetUrlAsync(GetUrlRequest request) $"https://token.botframework.com/api/botsignin/GetSignInUrl?{query}" ); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); return res.Body; } @@ -45,7 +45,7 @@ public async Task GetUrlAsync(GetUrlRequest request) $"https://token.botframework.com/api/botsignin/GetSignInResource?{query}" ); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); return res.Body; } diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs index 8255d89ce..5590812eb 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Common.Http; @@ -37,11 +37,11 @@ public BotTokenClient(IHttpClientFactory factory, CancellationToken cancellation public virtual async Task GetAsync(IHttpCredentials credentials, IHttpClient? http = null) { - return await credentials.Resolve(http ?? _http, [BotScope], _cancellationToken); + return await credentials.Resolve(http ?? _http, [BotScope], _cancellationToken).ConfigureAwait(false); } public async Task GetGraphAsync(IHttpCredentials credentials, IHttpClient? http = null) { - return await credentials.Resolve(http ?? _http, [GraphScope], _cancellationToken); + return await credentials.Resolve(http ?? _http, [GraphScope], _cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs index a32850ab1..aa25f4690 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Text.Json.Serialization; @@ -60,7 +60,7 @@ public ConversationClient(string serviceUrl, IHttpClientFactory factory, Cancell public async Task CreateAsync(CreateRequest request) { var req = HttpRequest.Post($"{ServiceUrl}v3/conversations", body: request); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); return res.Body; } diff --git a/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs b/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs index cd14260a0..f194649c4 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Text.Json.Serialization; @@ -35,14 +35,14 @@ public MeetingClient(string serviceUrl, IHttpClientFactory factory, Cancellation public async Task GetByIdAsync(string id) { var request = HttpRequest.Get($"{ServiceUrl}v1/meetings/{id}"); - var response = await _http.SendAsync(request, _cancellationToken); + var response = await _http.SendAsync(request, _cancellationToken).ConfigureAwait(false); return response.Body; } public async Task GetParticipantAsync(string meetingId, string id, string tenantId) { var request = HttpRequest.Get($"{ServiceUrl}v1/meetings/{Uri.EscapeDataString(meetingId)}/participants/{Uri.EscapeDataString(id)}?tenantId={Uri.EscapeDataString(tenantId)}"); - var response = await _http.SendAsync(request, _cancellationToken); + var response = await _http.SendAsync(request, _cancellationToken).ConfigureAwait(false); return response.Body; } } diff --git a/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs b/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs index d69c96a25..d8a446684 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Common.Http; @@ -32,20 +32,20 @@ public MemberClient(string serviceUrl, IHttpClientFactory factory, CancellationT public async Task> GetAsync(string conversationId) { var request = HttpRequest.Get($"{ServiceUrl}v3/conversations/{conversationId}/members"); - var response = await _http.SendAsync>(request, _cancellationToken); + var response = await _http.SendAsync>(request, _cancellationToken).ConfigureAwait(false); return response.Body; } public async Task GetByIdAsync(string conversationId, string memberId) { var request = HttpRequest.Get($"{ServiceUrl}v3/conversations/{conversationId}/members/{memberId}"); - var response = await _http.SendAsync(request, _cancellationToken); + var response = await _http.SendAsync(request, _cancellationToken).ConfigureAwait(false); return response.Body; } public async Task DeleteAsync(string conversationId, string memberId) { var request = HttpRequest.Delete($"{ServiceUrl}v3/conversations/{conversationId}/members/{memberId}"); - await _http.SendAsync(request, _cancellationToken); + await _http.SendAsync(request, _cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Clients/ReactionClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ReactionClient.cs index 8c34ba76c..d0695a845 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ReactionClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ReactionClient.cs @@ -63,7 +63,7 @@ public async Task AddAsync( // PUT v3/conversations/{conversationId}/activities/{activityId}/reactions/{reactionType} var url = $"{ServiceUrl}v3/conversations/{conversationId}/activities/{activityId}/reactions/{reactionType}"; var req = HttpRequest.Put(url); - await _http.SendAsync(req, cancellationToken != default ? cancellationToken : _cancellationToken); + await _http.SendAsync(req, cancellationToken != default ? cancellationToken : _cancellationToken).ConfigureAwait(false); } /// @@ -92,6 +92,6 @@ public async Task DeleteAsync( var req = HttpRequest.Delete(url); - await _http.SendAsync(req, cancellationToken != default ? cancellationToken : _cancellationToken); + await _http.SendAsync(req, cancellationToken != default ? cancellationToken : _cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs b/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs index 4ee056229..edd725ea5 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Common.Http; @@ -32,14 +32,14 @@ public TeamClient(string serviceUrl, IHttpClientFactory factory, CancellationTok public async Task GetByIdAsync(string id) { var request = HttpRequest.Get($"{ServiceUrl}v3/teams/{id}"); - var response = await _http.SendAsync(request, _cancellationToken); + var response = await _http.SendAsync(request, _cancellationToken).ConfigureAwait(false); return response.Body; } public async Task> GetConversationsAsync(string id) { var request = HttpRequest.Get($"{ServiceUrl}v3/teams/{id}/conversations"); - var response = await _http.SendAsync>(request, _cancellationToken); + var response = await _http.SendAsync>(request, _cancellationToken).ConfigureAwait(false); return response.Body; } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs index cf264d6a9..4165db13f 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs @@ -39,7 +39,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var query = QueryString.Serialize(request); var req = HttpRequest.Get($"https://token.botframework.com/api/usertoken/GetToken?{query}"); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); return res.Body; } @@ -47,7 +47,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var query = QueryString.Serialize(request); var req = HttpRequest.Post($"https://token.botframework.com/api/usertoken/GetAadTokens?{query}", body: request); - var res = await _http.SendAsync>(req, _cancellationToken); + var res = await _http.SendAsync>(req, _cancellationToken).ConfigureAwait(false); return res.Body; } @@ -55,7 +55,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var query = QueryString.Serialize(request); var req = HttpRequest.Get($"https://token.botframework.com/api/usertoken/GetTokenStatus?{query}"); - var res = await _http.SendAsync>(req, _cancellationToken); + var res = await _http.SendAsync>(req, _cancellationToken).ConfigureAwait(false); return res.Body; } @@ -63,7 +63,7 @@ public async Task SignOutAsync(SignOutRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Delete($"https://token.botframework.com/api/usertoken/SignOut?{query}"); - await _http.SendAsync(req, _cancellationToken); + await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); } public async Task ExchangeAsync(ExchangeTokenRequest request) @@ -82,7 +82,7 @@ public async Task SignOutAsync(SignOutRequest request) var req = HttpRequest.Post($"https://token.botframework.com/api/usertoken/exchange?{query}", body); req.Headers.Add("Content-Type", new List() { "application/json" }); - var res = await _http.SendAsync(req, _cancellationToken); + var res = await _http.SendAsync(req, _cancellationToken).ConfigureAwait(false); return res.Body; } diff --git a/Libraries/Microsoft.Teams.Apps.Testing/Plugins/TestPlugin.cs b/Libraries/Microsoft.Teams.Apps.Testing/Plugins/TestPlugin.cs index 13e71fea4..02676b615 100644 --- a/Libraries/Microsoft.Teams.Apps.Testing/Plugins/TestPlugin.cs +++ b/Libraries/Microsoft.Teams.Apps.Testing/Plugins/TestPlugin.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Net; @@ -139,7 +139,7 @@ public async Task Do(IToken token, IActivity activity, IDictionary Do(ActivityEvent @event, CancellationToken cancellationToken = default) @@ -153,7 +153,7 @@ await Events( "message", new TestMessageEvent() { Message = message.Text }, cancellationToken - ); + ).ConfigureAwait(false); } var @out = await Events( @@ -161,7 +161,7 @@ await Events( EventType.Activity, @event, cancellationToken - ); + ).ConfigureAwait(false); var res = (Response?)@out; @@ -179,7 +179,7 @@ await Events( EventType.Error, new ErrorEvent() { Exception = ex }, cancellationToken - ); + ).ConfigureAwait(false); return new(HttpStatusCode.InternalServerError, ex.ToString()); } diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Activity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Activity.cs index 2e9416716..d11a972d3 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Activity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Activity.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Api.Activities; @@ -22,7 +22,7 @@ public static App OnActivity(this App app, Func, Task> handl { app.Router.Register(async (context) => { - await handler(context); + await handler(context).ConfigureAwait(false); return null; }); @@ -33,7 +33,7 @@ public static App OnActivity(this App app, Func, Cancellatio { app.Router.Register(async (context) => { - await handler(context, context.CancellationToken); + await handler(context, context.CancellationToken).ConfigureAwait(false); return null; }); @@ -60,7 +60,7 @@ public static App OnActivity(this App app, ActivityType type, Func { - await handler(context); + await handler(context).ConfigureAwait(false); return null; }, Selector = (activity) => activity.Type.Equals(type), @@ -77,7 +77,7 @@ public static App OnActivity(this App app, ActivityType type, Func { - await handler(context, context.CancellationToken); + await handler(context, context.CancellationToken).ConfigureAwait(false); return null; }, Selector = (activity) => activity.Type.Equals(type), @@ -120,7 +120,7 @@ public static App OnActivity(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async (context) => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = (activity) => activity.GetType() == typeof(TActivity), @@ -137,7 +137,7 @@ public static App OnActivity(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async (context) => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = (activity) => activity.GetType() == typeof(TActivity), @@ -181,7 +181,7 @@ public static App OnActivity(this App app, Func select, Func { - await handler(context); + await handler(context).ConfigureAwait(false); return null; } }); @@ -198,7 +198,7 @@ public static App OnActivity(this App app, Func select, Func { - await handler(context, context.CancellationToken); + await handler(context, context.CancellationToken).ConfigureAwait(false); return null; } }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/CommandActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/CommandActivity.cs index 868f486cf..80d3f8cfd 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/CommandActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/CommandActivity.cs @@ -22,7 +22,7 @@ public static App OnCommand(this App app, Func, Task> Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is CommandActivity @@ -39,7 +39,7 @@ public static App OnCommand(this App app, Func, Cancel Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is CommandActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/CommandResultActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/CommandResultActivity.cs index 556cc2f30..24dba7b11 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/CommandResultActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/CommandResultActivity.cs @@ -22,7 +22,7 @@ public static App OnCommandResult(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is CommandResultActivity @@ -39,7 +39,7 @@ public static App OnCommandResult(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is CommandResultActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelCreatedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelCreatedActivity.cs index 21f8c16ad..a6e68df88 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelCreatedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelCreatedActivity.cs @@ -33,7 +33,7 @@ public static App OnChannelCreated(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnChannelCreated(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelDeletedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelDeletedActivity.cs index be6c08307..8a6592677 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelDeletedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelDeletedActivity.cs @@ -33,7 +33,7 @@ public static App OnChannelDeleted(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnChannelDeleted(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberAddedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberAddedActivity.cs index 30965c21b..f9bedce1d 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberAddedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberAddedActivity.cs @@ -30,7 +30,7 @@ public static App OnChannelMemberAdded(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -55,7 +55,7 @@ public static App OnChannelMemberAdded(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberRemovedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberRemovedActivity.cs index e91ddd05c..d0db53dd0 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberRemovedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberRemovedActivity.cs @@ -30,7 +30,7 @@ public static App OnChannelMemberRemoved(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -55,7 +55,7 @@ public static App OnChannelMemberRemoved(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRenamedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRenamedActivity.cs index 2a37b40a3..50e8e28f4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRenamedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRenamedActivity.cs @@ -33,7 +33,7 @@ public static App OnChannelRenamed(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnChannelRenamed(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRestoredActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRestoredActivity.cs index c15314ec2..fb35c10b0 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRestoredActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRestoredActivity.cs @@ -33,7 +33,7 @@ public static App OnChannelRestored(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnChannelRestored(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelSharedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelSharedActivity.cs index f8fc3c633..574fca7eb 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelSharedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelSharedActivity.cs @@ -30,7 +30,7 @@ public static App OnChannelShared(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -55,7 +55,7 @@ public static App OnChannelShared(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelUnsharedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelUnsharedActivity.cs index dd7df0206..62c351361 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelUnsharedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelUnsharedActivity.cs @@ -30,7 +30,7 @@ public static App OnChannelUnShared(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -55,7 +55,7 @@ public static App OnChannelUnShared(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationEndActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationEndActivity.cs index dd42e2c78..541712ec2 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationEndActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationEndActivity.cs @@ -25,7 +25,7 @@ public static App OnConversationEnd(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is EndOfConversationActivity @@ -42,7 +42,7 @@ public static App OnConversationEnd(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is EndOfConversationActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationUpdateActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationUpdateActivity.cs index d732388f0..91f57d4ea 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationUpdateActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationUpdateActivity.cs @@ -35,7 +35,7 @@ public static App OnConversationUpdate(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is ConversationUpdateActivity @@ -52,7 +52,7 @@ public static App OnConversationUpdate(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is ConversationUpdateActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersAddedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersAddedActivity.cs index 9d28783e3..9ff156cb7 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersAddedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersAddedActivity.cs @@ -33,7 +33,7 @@ public static App OnMembersAdded(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnMembersAdded(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersRemovedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersRemovedActivity.cs index 60fcf0a27..7245a9ad4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersRemovedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersRemovedActivity.cs @@ -33,7 +33,7 @@ public static App OnMembersRemoved(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnMembersRemoved(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamArchivedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamArchivedActivity.cs index 17dbdc673..3e92fbca7 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamArchivedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamArchivedActivity.cs @@ -33,7 +33,7 @@ public static App OnTeamArchived(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnTeamArchived(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamDeletedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamDeletedActivity.cs index d51f536e1..fd895f986 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamDeletedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamDeletedActivity.cs @@ -35,7 +35,7 @@ public static App OnTeamDeleted(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -60,7 +60,7 @@ public static App OnTeamHardDeleted(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -85,7 +85,7 @@ public static App OnTeamDeleted(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => @@ -110,7 +110,7 @@ public static App OnTeamHardDeleted(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRenamedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRenamedActivity.cs index 48d1cdd7a..ce17b6076 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRenamedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRenamedActivity.cs @@ -33,7 +33,7 @@ public static App OnTeamRenamed(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnTeamRenamed(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRestoredActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRestoredActivity.cs index a66b48a67..d4a7db247 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRestoredActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRestoredActivity.cs @@ -33,7 +33,7 @@ public static App OnTeamRestored(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnTeamRestored(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamUnArchivedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamUnArchivedActivity.cs index 5bee7e713..69676cac3 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamUnArchivedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamUnArchivedActivity.cs @@ -33,7 +33,7 @@ public static App OnTeamUnArchived(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -58,7 +58,7 @@ public static App OnTeamUnArchived(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/EventActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/EventActivity.cs index 4a4d0e7d2..5aae450e8 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/EventActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/EventActivity.cs @@ -33,7 +33,7 @@ public static App OnEvent(this App app, Func, Task> hand Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is EventActivity @@ -50,7 +50,7 @@ public static App OnEvent(this App app, Func, Cancellati Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is EventActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingEndActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingEndActivity.cs index ae7859c49..f7b625ad0 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingEndActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingEndActivity.cs @@ -35,7 +35,7 @@ public static App OnMeetingEnd(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MeetingEndActivity @@ -52,7 +52,7 @@ public static App OnMeetingEnd(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MeetingEndActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingJoinActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingJoinActivity.cs index ccb28fb9c..715792cf1 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingJoinActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingJoinActivity.cs @@ -35,7 +35,7 @@ public static App OnMeetingJoin(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MeetingParticipantJoinActivity @@ -52,7 +52,7 @@ public static App OnMeetingJoin(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MeetingParticipantJoinActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingLeaveActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingLeaveActivity.cs index e17df268b..02eca4589 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingLeaveActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingLeaveActivity.cs @@ -35,7 +35,7 @@ public static App OnMeetingLeave(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MeetingParticipantLeaveActivity @@ -52,7 +52,7 @@ public static App OnMeetingLeave(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MeetingParticipantLeaveActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingStartActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingStartActivity.cs index 8c9e6798e..ede80be6d 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingStartActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingStartActivity.cs @@ -35,7 +35,7 @@ public static App OnMeetingStart(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MeetingStartActivity @@ -52,7 +52,7 @@ public static App OnMeetingStart(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MeetingStartActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/ReadReceiptActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/ReadReceiptActivity.cs index 86274b986..511fe6355 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/ReadReceiptActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/ReadReceiptActivity.cs @@ -35,7 +35,7 @@ public static App OnReadReceipt(this App app, Func Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is ReadReceiptActivity @@ -52,7 +52,7 @@ public static App OnReadReceipt(this App app, Func Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is ReadReceiptActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallActivity.cs index f65d3c236..78b8779f2 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallActivity.cs @@ -30,7 +30,7 @@ public static App OnInstall(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -55,7 +55,7 @@ public static App OnInstall(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallUpdateActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallUpdateActivity.cs index dbcf3ce51..6c255157b 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallUpdateActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallUpdateActivity.cs @@ -32,7 +32,7 @@ public static App OnInstallUpdate(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is InstallUpdateActivity @@ -49,7 +49,7 @@ public static App OnInstallUpdate(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is InstallUpdateActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Installs/UnInstallActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Installs/UnInstallActivity.cs index 55ff3af3c..d5aa9a1c1 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Installs/UnInstallActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Installs/UnInstallActivity.cs @@ -30,7 +30,7 @@ public static App OnUnInstall(this App app, Func Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -55,7 +55,7 @@ public static App OnUnInstall(this App app, Func Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/AdaptiveCards/ActionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/AdaptiveCards/ActionActivity.cs index 2df8ce56e..0d837b278 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/AdaptiveCards/ActionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/AdaptiveCards/ActionActivity.cs @@ -27,7 +27,7 @@ public static App OnAdaptiveCardAction(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is AdaptiveCards.ActionActivity @@ -42,7 +42,7 @@ public static App OnAdaptiveCardAction(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is AdaptiveCards.ActionActivity }); @@ -55,7 +55,7 @@ public static App OnAdaptiveCardAction(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is AdaptiveCards.ActionActivity }); @@ -70,7 +70,7 @@ public static App OnAdaptiveCardAction(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is AdaptiveCards.ActionActivity @@ -85,7 +85,7 @@ public static App OnAdaptiveCardAction(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is AdaptiveCards.ActionActivity }); @@ -98,7 +98,7 @@ public static App OnAdaptiveCardAction(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is AdaptiveCards.ActionActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/FetchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/FetchActivity.cs index 070bca21b..93d389f26 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/FetchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/FetchActivity.cs @@ -27,7 +27,7 @@ public static App OnConfigFetch(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is Configs.FetchActivity @@ -42,7 +42,7 @@ public static App OnConfigFetch(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Configs.FetchActivity }); @@ -55,7 +55,7 @@ public static App OnConfigFetch(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Configs.FetchActivity }); @@ -70,7 +70,7 @@ public static App OnConfigFetch(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is Configs.FetchActivity @@ -85,7 +85,7 @@ public static App OnConfigFetch(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Configs.FetchActivity }); @@ -98,7 +98,7 @@ public static App OnConfigFetch(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Configs.FetchActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/SubmitActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/SubmitActivity.cs index 7ab3d8b50..7acf38eb4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/SubmitActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/SubmitActivity.cs @@ -27,7 +27,7 @@ public static App OnConfigSubmit(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is Configs.SubmitActivity @@ -42,7 +42,7 @@ public static App OnConfigSubmit(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Configs.SubmitActivity }); @@ -55,7 +55,7 @@ public static App OnConfigSubmit(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Configs.SubmitActivity }); @@ -70,7 +70,7 @@ public static App OnConfigSubmit(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is Configs.SubmitActivity @@ -85,7 +85,7 @@ public static App OnConfigSubmit(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Configs.SubmitActivity }); @@ -98,7 +98,7 @@ public static App OnConfigSubmit(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Configs.SubmitActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/ExecuteActionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/ExecuteActionActivity.cs index e04183b3f..498caf15e 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/ExecuteActionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/ExecuteActionActivity.cs @@ -23,7 +23,7 @@ public static App OnExecuteAction(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is ExecuteActionActivity @@ -53,7 +53,7 @@ public static App OnExecuteAction(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is ExecuteActionActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/FileConsentActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/FileConsentActivity.cs index fa3ee7c8a..d7f3406c3 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/FileConsentActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/FileConsentActivity.cs @@ -23,7 +23,7 @@ public static App OnFileConsent(this App app, Func Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is FileConsentActivity @@ -53,7 +53,7 @@ public static App OnFileConsent(this App app, Func Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is FileConsentActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/HandoffActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/HandoffActivity.cs index 1b0a3ee31..e361376ca 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/HandoffActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/HandoffActivity.cs @@ -23,7 +23,7 @@ public static App OnHandoff(this App app, Func, Task> Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is HandoffActivity @@ -53,7 +53,7 @@ public static App OnHandoff(this App app, Func, Cancel Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is HandoffActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/InvokeActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/InvokeActivity.cs index c3763c919..353b3db91 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/InvokeActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/InvokeActivity.cs @@ -39,7 +39,7 @@ public static App OnInvoke(this App app, Func, Task> ha Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is InvokeActivity @@ -67,7 +67,7 @@ public static App OnInvoke(this App app, Func, Task await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is InvokeActivity }); @@ -82,7 +82,7 @@ public static App OnInvoke(this App app, Func, Cancella Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is InvokeActivity @@ -110,7 +110,7 @@ public static App OnInvoke(this App app, Func, Cancella { Name = ActivityType.Invoke, Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is InvokeActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/AnonQueryLinkActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/AnonQueryLinkActivity.cs index ef17799da..d1e6cc408 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/AnonQueryLinkActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/AnonQueryLinkActivity.cs @@ -26,7 +26,7 @@ public static App OnAnonQueryLink(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.AnonQueryLinkActivity @@ -41,7 +41,7 @@ public static App OnAnonQueryLink(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.AnonQueryLinkActivity }); @@ -54,7 +54,7 @@ public static App OnAnonQueryLink(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.AnonQueryLinkActivity }); @@ -69,7 +69,7 @@ public static App OnAnonQueryLink(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.AnonQueryLinkActivity @@ -84,7 +84,7 @@ public static App OnAnonQueryLink(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.AnonQueryLinkActivity }); @@ -97,7 +97,7 @@ public static App OnAnonQueryLink(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.AnonQueryLinkActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/CardButtonClickedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/CardButtonClickedActivity.cs index 63d6ffcfd..c36ae0ce6 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/CardButtonClickedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/CardButtonClickedActivity.cs @@ -26,7 +26,7 @@ public static App OnCardButtonClicked(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.CardButtonClickedActivity @@ -43,7 +43,7 @@ public static App OnCardButtonClicked(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.CardButtonClickedActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/FetchTaskActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/FetchTaskActivity.cs index b1755444e..e6fad7716 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/FetchTaskActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/FetchTaskActivity.cs @@ -26,7 +26,7 @@ public static App OnFetchTask(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.FetchTaskActivity @@ -41,7 +41,7 @@ public static App OnFetchTask(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.FetchTaskActivity }); @@ -54,7 +54,7 @@ public static App OnFetchTask(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.FetchTaskActivity }); @@ -69,7 +69,7 @@ public static App OnFetchTask(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.FetchTaskActivity @@ -84,7 +84,7 @@ public static App OnFetchTask(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.FetchTaskActivity }); @@ -97,7 +97,7 @@ public static App OnFetchTask(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.FetchTaskActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryActivity.cs index 1f682293c..abd60c0c3 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryActivity.cs @@ -26,7 +26,7 @@ public static App OnQuery(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.QueryActivity @@ -41,7 +41,7 @@ public static App OnQuery(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QueryActivity }); @@ -54,7 +54,7 @@ public static App OnQuery(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QueryActivity }); @@ -69,7 +69,7 @@ public static App OnQuery(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.QueryActivity @@ -84,7 +84,7 @@ public static App OnQuery(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QueryActivity }); @@ -97,7 +97,7 @@ public static App OnQuery(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QueryActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryLinkActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryLinkActivity.cs index 4dd6a4400..0d0659d06 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryLinkActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryLinkActivity.cs @@ -26,7 +26,7 @@ public static App OnQueryLink(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.QueryLinkActivity @@ -41,7 +41,7 @@ public static App OnQueryLink(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QueryLinkActivity }); @@ -54,7 +54,7 @@ public static App OnQueryLink(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QueryLinkActivity }); @@ -69,7 +69,7 @@ public static App OnQueryLink(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.QueryLinkActivity @@ -84,7 +84,7 @@ public static App OnQueryLink(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QueryLinkActivity }); @@ -97,7 +97,7 @@ public static App OnQueryLink(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QueryLinkActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QuerySettingsUrlActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QuerySettingsUrlActivity.cs index 465ea84e5..9e89ff05f 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QuerySettingsUrlActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QuerySettingsUrlActivity.cs @@ -26,7 +26,7 @@ public static App OnQuerySettingsUrl(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.QuerySettingUrlActivity @@ -41,7 +41,7 @@ public static App OnQuerySettingsUrl(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QuerySettingUrlActivity }); @@ -54,7 +54,7 @@ public static App OnQuerySettingsUrl(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QuerySettingUrlActivity }); @@ -69,7 +69,7 @@ public static App OnQuerySettingsUrl(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.QuerySettingUrlActivity @@ -84,7 +84,7 @@ public static App OnQuerySettingsUrl(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QuerySettingUrlActivity }); @@ -97,7 +97,7 @@ public static App OnQuerySettingsUrl(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.QuerySettingUrlActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SelectItemActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SelectItemActivity.cs index 3c19e7430..82941368a 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SelectItemActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SelectItemActivity.cs @@ -26,7 +26,7 @@ public static App OnSelectItem(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.SelectItemActivity @@ -41,7 +41,7 @@ public static App OnSelectItem(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.SelectItemActivity }); @@ -54,7 +54,7 @@ public static App OnSelectItem(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.SelectItemActivity }); @@ -69,7 +69,7 @@ public static App OnSelectItem(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.SelectItemActivity @@ -84,7 +84,7 @@ public static App OnSelectItem(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.SelectItemActivity }); @@ -97,7 +97,7 @@ public static App OnSelectItem(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.SelectItemActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SettingsActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SettingsActivity.cs index 0f3161685..dc18ffdd4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SettingsActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SettingsActivity.cs @@ -26,7 +26,7 @@ public static App OnSetting(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.SettingActivity @@ -43,7 +43,7 @@ public static App OnSetting(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.SettingActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SubmitActionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SubmitActionActivity.cs index 1878e2e32..4b8fafacb 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SubmitActionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SubmitActionActivity.cs @@ -26,7 +26,7 @@ public static App OnSubmitAction(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.SubmitActionActivity @@ -41,7 +41,7 @@ public static App OnSubmitAction(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.SubmitActionActivity }); @@ -54,7 +54,7 @@ public static App OnSubmitAction(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.SubmitActionActivity }); @@ -69,7 +69,7 @@ public static App OnSubmitAction(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageExtensions.SubmitActionActivity @@ -84,7 +84,7 @@ public static App OnSubmitAction(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.SubmitActionActivity }); @@ -97,7 +97,7 @@ public static App OnSubmitAction(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is MessageExtensions.SubmitActionActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FeedbackActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FeedbackActivity.cs index f3c89eb3d..bc16d9c7d 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FeedbackActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FeedbackActivity.cs @@ -37,7 +37,7 @@ public static App OnFeedback(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is Messages.SubmitActionActivity submitAction && submitAction.Value?.ActionName == "feedback" @@ -57,7 +57,7 @@ public static App OnFeedback(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is Messages.SubmitActionActivity submitAction && submitAction.Value?.ActionName == "feedback" diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/SubmitActionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/SubmitActionActivity.cs index 38f700d52..e726dad21 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/SubmitActionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/SubmitActionActivity.cs @@ -26,7 +26,7 @@ public static App OnSubmitAction(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is Messages.SubmitActionActivity @@ -56,7 +56,7 @@ public static App OnSubmitAction(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is Messages.SubmitActionActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/AnswerSearchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/AnswerSearchActivity.cs index 3d0478913..cf8f8e305 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/AnswerSearchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/AnswerSearchActivity.cs @@ -32,7 +32,7 @@ public static App OnAnswerSearch(this App app, Func, Ta Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -55,7 +55,7 @@ public static App OnAnswerSearch(this App app, Func, Ta { Name = string.Join("/", [ActivityType.Invoke, Name.Search, SearchType.SearchAnswer]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => { if (activity is SearchActivity search) @@ -76,7 +76,7 @@ public static App OnAnswerSearch(this App app, Func, Ta { Name = string.Join("/", [ActivityType.Invoke, Name.Search, SearchType.SearchAnswer]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => { if (activity is SearchActivity search) @@ -99,7 +99,7 @@ public static App OnAnswerSearch(this App app, Func, Ca Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => @@ -122,7 +122,7 @@ public static App OnAnswerSearch(this App app, Func, Ca { Name = string.Join("/", [ActivityType.Invoke, Name.Search, SearchType.SearchAnswer]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => { if (activity is SearchActivity search) @@ -143,7 +143,7 @@ public static App OnAnswerSearch(this App app, Func, Ca { Name = string.Join("/", [ActivityType.Invoke, Name.Search, SearchType.SearchAnswer]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => { if (activity is SearchActivity search) diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/SearchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/SearchActivity.cs index fcc2a012f..437a6e8f0 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/SearchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/SearchActivity.cs @@ -21,7 +21,7 @@ public static App OnSearch(this App app, Func, Task> ha Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is SearchActivity @@ -36,7 +36,7 @@ public static App OnSearch(this App app, Func, Task await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is SearchActivity }); @@ -49,7 +49,7 @@ public static App OnSearch(this App app, Func, Task await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is SearchActivity }); @@ -64,7 +64,7 @@ public static App OnSearch(this App app, Func, Cancella Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is SearchActivity @@ -79,7 +79,7 @@ public static App OnSearch(this App app, Func, Cancella { Name = string.Join("/", [ActivityType.Invoke, Name.Search]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is SearchActivity }); @@ -92,7 +92,7 @@ public static App OnSearch(this App app, Func, Cancella { Name = string.Join("/", [ActivityType.Invoke, Name.Search]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is SearchActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/TypeaheadSearchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/TypeaheadSearchActivity.cs index 2d6976842..0e29291aa 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/TypeaheadSearchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/TypeaheadSearchActivity.cs @@ -32,7 +32,7 @@ public static App OnTypeaheadSearch(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -55,7 +55,7 @@ public static App OnTypeaheadSearch(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Search, SearchType.Typeahead]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => { if (activity is SearchActivity search) @@ -76,7 +76,7 @@ public static App OnTypeaheadSearch(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Search, SearchType.Typeahead]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => { if (activity is SearchActivity search) @@ -99,7 +99,7 @@ public static App OnTypeaheadSearch(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => @@ -122,7 +122,7 @@ public static App OnTypeaheadSearch(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Search, SearchType.Typeahead]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => { if (activity is SearchActivity search) @@ -143,7 +143,7 @@ public static App OnTypeaheadSearch(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Search, SearchType.Typeahead]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => { if (activity is SearchActivity search) diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs index 5fed8f779..9649225fe 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs @@ -29,7 +29,7 @@ public static App OnSignInFailure(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is SignIn.FailureActivity @@ -63,7 +63,7 @@ public static App OnSignInFailure(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is SignIn.FailureActivity }); @@ -81,7 +81,7 @@ public static App OnSignInFailure(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is SignIn.FailureActivity @@ -115,7 +115,7 @@ public static App OnSignInFailure(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is SignIn.FailureActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/TokenExchangeActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/TokenExchangeActivity.cs index b563e325d..daf4fb369 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/TokenExchangeActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/TokenExchangeActivity.cs @@ -23,7 +23,7 @@ public static App OnTokenExchange(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is SignIn.TokenExchangeActivity @@ -38,7 +38,7 @@ public static App OnTokenExchange(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is SignIn.TokenExchangeActivity }); @@ -51,7 +51,7 @@ public static App OnTokenExchange(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is SignIn.TokenExchangeActivity }); @@ -64,7 +64,7 @@ public static App OnTokenExchange(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is SignIn.TokenExchangeActivity }); @@ -79,7 +79,7 @@ public static App OnTokenExchange(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is SignIn.TokenExchangeActivity @@ -94,7 +94,7 @@ public static App OnTokenExchange(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is SignIn.TokenExchangeActivity }); @@ -107,7 +107,7 @@ public static App OnTokenExchange(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is SignIn.TokenExchangeActivity }); @@ -120,7 +120,7 @@ public static App OnTokenExchange(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is SignIn.TokenExchangeActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/VerifyStateAcitivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/VerifyStateAcitivity.cs index 2304b306a..8d004317e 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/VerifyStateAcitivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/VerifyStateAcitivity.cs @@ -23,7 +23,7 @@ public static App OnVerifyState(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is SignIn.VerifyStateActivity @@ -51,7 +51,7 @@ public static App OnVerifyState(this App app, Func await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is SignIn.VerifyStateActivity }); @@ -66,7 +66,7 @@ public static App OnVerifyState(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is SignIn.VerifyStateActivity @@ -94,7 +94,7 @@ public static App OnVerifyState(this App app, Func await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is SignIn.VerifyStateActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/FetchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/FetchActivity.cs index 07e64e29c..a2b81454c 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/FetchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/FetchActivity.cs @@ -26,7 +26,7 @@ public static App OnTabFetch(this App app, Func, Ta Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is Tabs.FetchActivity @@ -41,7 +41,7 @@ public static App OnTabFetch(this App app, Func, Ta { Name = string.Join("/", [ActivityType.Invoke, Name.Tabs.Fetch]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Tabs.FetchActivity }); @@ -54,7 +54,7 @@ public static App OnTabFetch(this App app, Func, Ta { Name = string.Join("/", [ActivityType.Invoke, Name.Tabs.Fetch]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Tabs.FetchActivity }); @@ -69,7 +69,7 @@ public static App OnTabFetch(this App app, Func, Ca Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is Tabs.FetchActivity @@ -84,7 +84,7 @@ public static App OnTabFetch(this App app, Func, Ca { Name = string.Join("/", [ActivityType.Invoke, Name.Tabs.Fetch]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Tabs.FetchActivity }); @@ -97,7 +97,7 @@ public static App OnTabFetch(this App app, Func, Ca { Name = string.Join("/", [ActivityType.Invoke, Name.Tabs.Fetch]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Tabs.FetchActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/SubmitActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/SubmitActivity.cs index 2003d5d8e..359c8d8d4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/SubmitActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/SubmitActivity.cs @@ -26,7 +26,7 @@ public static App OnTabSubmit(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is Tabs.SubmitActivity @@ -41,7 +41,7 @@ public static App OnTabSubmit(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Tabs.Submit]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Tabs.SubmitActivity }); @@ -54,7 +54,7 @@ public static App OnTabSubmit(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Tabs.Submit]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Tabs.SubmitActivity }); @@ -69,7 +69,7 @@ public static App OnTabSubmit(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is Tabs.SubmitActivity @@ -84,7 +84,7 @@ public static App OnTabSubmit(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Tabs.Submit]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Tabs.SubmitActivity }); @@ -97,7 +97,7 @@ public static App OnTabSubmit(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Tabs.Submit]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Tabs.SubmitActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/FetchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/FetchActivity.cs index 9b2d7ee4d..5e8d4ac64 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/FetchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/FetchActivity.cs @@ -23,7 +23,7 @@ public static App OnTaskFetch(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is Tasks.FetchActivity @@ -38,7 +38,7 @@ public static App OnTaskFetch(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Tasks.Fetch]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Tasks.FetchActivity }); @@ -51,7 +51,7 @@ public static App OnTaskFetch(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Tasks.Fetch]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Tasks.FetchActivity }); @@ -66,7 +66,7 @@ public static App OnTaskFetch(this App app, Func, Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is Tasks.FetchActivity @@ -81,7 +81,7 @@ public static App OnTaskFetch(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Tasks.Fetch]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Tasks.FetchActivity }); @@ -94,7 +94,7 @@ public static App OnTaskFetch(this App app, Func, { Name = string.Join("/", [ActivityType.Invoke, Name.Tasks.Fetch]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Tasks.FetchActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/SubmitActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/SubmitActivity.cs index e50d1c7ae..d4e0d08b1 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/SubmitActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/SubmitActivity.cs @@ -23,7 +23,7 @@ public static App OnTaskSubmit(this App app, Func Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is Tasks.SubmitActivity @@ -38,7 +38,7 @@ public static App OnTaskSubmit(this App app, Func { Name = string.Join("/", [ActivityType.Invoke, Name.Tasks.Submit]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Tasks.SubmitActivity }); @@ -51,7 +51,7 @@ public static App OnTaskSubmit(this App app, Func { Name = string.Join("/", [ActivityType.Invoke, Name.Tasks.Submit]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType()), + Handler = async context => await handler(context.ToActivityType()).ConfigureAwait(false), Selector = activity => activity is Tasks.SubmitActivity }); @@ -66,7 +66,7 @@ public static App OnTaskSubmit(this App app, Func Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is Tasks.SubmitActivity @@ -81,7 +81,7 @@ public static App OnTaskSubmit(this App app, Func { Name = string.Join("/", [ActivityType.Invoke, Name.Tasks.Submit]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Tasks.SubmitActivity }); @@ -94,7 +94,7 @@ public static App OnTaskSubmit(this App app, Func { Name = string.Join("/", [ActivityType.Invoke, Name.Tasks.Submit]), Type = app.Status is null ? RouteType.System : RouteType.User, - Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false), Selector = activity => activity is Tasks.SubmitActivity }); diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageActivity.cs index dc8b75056..b2e573f2f 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageActivity.cs @@ -44,7 +44,7 @@ public static App OnMessage(this App app, Func, Task> Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageActivity @@ -61,7 +61,7 @@ public static App OnMessage(this App app, Func, Cancel Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageActivity @@ -78,7 +78,7 @@ public static App OnMessage(this App app, string pattern, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -104,7 +104,7 @@ public static App OnMessage(this App app, string pattern, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => @@ -129,7 +129,7 @@ public static App OnMessage(this App app, Regex regex, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -154,7 +154,7 @@ public static App OnMessage(this App app, Regex regex, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageDeleteActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageDeleteActivity.cs index c210247e5..30cbc365c 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageDeleteActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageDeleteActivity.cs @@ -25,7 +25,7 @@ public static App OnMessageDelete(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageDeleteActivity @@ -42,7 +42,7 @@ public static App OnMessageDelete(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageDeleteActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageReactionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageReactionActivity.cs index 05aa4d47f..53aa04ea0 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageReactionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageReactionActivity.cs @@ -53,7 +53,7 @@ public static App OnMessageReaction(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageReactionActivity @@ -70,7 +70,7 @@ public static App OnMessageReaction(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageReactionActivity @@ -87,7 +87,7 @@ public static App OnMessageReactionAdded(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -112,7 +112,7 @@ public static App OnMessageReactionAdded(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => @@ -137,7 +137,7 @@ public static App OnMessageReactionRemoved(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => @@ -162,7 +162,7 @@ public static App OnMessageReactionRemoved(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageUpdateActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageUpdateActivity.cs index cf126b2ed..323e5a55c 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageUpdateActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageUpdateActivity.cs @@ -25,7 +25,7 @@ public static App OnMessageUpdate(this App app, Func { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageUpdateActivity @@ -42,7 +42,7 @@ public static App OnMessageUpdate(this App app, Func { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is MessageUpdateActivity diff --git a/Libraries/Microsoft.Teams.Apps/Activities/TypingActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/TypingActivity.cs index a4bb7c7b6..43831a1c6 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/TypingActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/TypingActivity.cs @@ -22,7 +22,7 @@ public static App OnTyping(this App app, Func, Task> ha Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType()); + await handler(context.ToActivityType()).ConfigureAwait(false); return null; }, Selector = activity => activity is TypingActivity @@ -39,7 +39,7 @@ public static App OnTyping(this App app, Func, Cancella Type = app.Status is null ? RouteType.System : RouteType.User, Handler = async context => { - await handler(context.ToActivityType(), context.CancellationToken); + await handler(context.ToActivityType(), context.CancellationToken).ConfigureAwait(false); return null; }, Selector = activity => activity is TypingActivity diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index b56a8c9d0..45e66200d 100644 --- a/Libraries/Microsoft.Teams.Apps/App.cs +++ b/Libraries/Microsoft.Teams.Apps/App.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Api; @@ -131,7 +131,7 @@ public async Task Start(CancellationToken cancellationToken = default) { try { - var res = await Api.Bots.Token.GetAsync(Credentials, TokenClient); + var res = await Api.Bots.Token.GetAsync(Credentials, TokenClient).ConfigureAwait(false); Token = new JsonWebToken(res.AccessToken); } catch (Exception ex) @@ -145,12 +145,12 @@ public async Task Start(CancellationToken cancellationToken = default) foreach (var plugin in Plugins) { - await plugin.OnInit(this, cancellationToken); + await plugin.OnInit(this, cancellationToken).ConfigureAwait(false); } foreach (var plugin in Plugins) { - await plugin.OnStart(this, cancellationToken); + await plugin.OnStart(this, cancellationToken).ConfigureAwait(false); } Status = Apps.Status.Started; @@ -162,7 +162,7 @@ await Events.Emit( null!, EventType.Error, new ErrorEvent() { Exception = ex } - ); + ).ConfigureAwait(false); } } @@ -201,14 +201,14 @@ public async Task Send(string conversationId, T activity, ConversationType throw new Exception("no plugin that can send activities was found"); } - var res = await sender.Send(activity, reference, cancellationToken); + var res = await sender.Send(activity, reference, cancellationToken).ConfigureAwait(false); await Events.Emit( sender, EventType.ActivitySent, new ActivitySentEvent() { Activity = res }, cancellationToken - ); + ).ConfigureAwait(false); return res; } @@ -219,7 +219,7 @@ await Events.Emit( /// the text to send public async Task Send(string conversationId, string text, ConversationType? conversationType = null, string? serviceUrl = null, CancellationToken cancellationToken = default) { - return await Send(conversationId, new MessageActivity(text), conversationType, serviceUrl, cancellationToken); + return await Send(conversationId, new MessageActivity(text), conversationType, serviceUrl, cancellationToken).ConfigureAwait(false); } /// @@ -228,7 +228,7 @@ public async Task Send(string conversationId, string text, Conv /// the card to send as an attachment public async Task Send(string conversationId, Cards.AdaptiveCard card, ConversationType? conversationType = null, string? serviceUrl = null, CancellationToken cancellationToken = default) { - return await Send(conversationId, new MessageActivity().AddAttachment(card), conversationType, serviceUrl, cancellationToken); + return await Send(conversationId, new MessageActivity().AddAttachment(card), conversationType, serviceUrl, cancellationToken).ConfigureAwait(false); } /// @@ -245,7 +245,7 @@ public async Task Process(ISenderPlugin sender, IToken token, IActivit Token = token, Activity = activity, Extra = extra - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); } /// @@ -296,11 +296,14 @@ private async Task Process(ISenderPlugin sender, ActivityEvent @event, UserId = @event.Activity.From.Id, ChannelId = @event.Activity.ChannelId, ConnectionName = OAuth.DefaultConnectionName - }); + }).ConfigureAwait(false); userToken = new JsonWebToken(tokenResponse); } - catch { } + catch (Exception ex) + { + Logger.Debug($"Token retrieval failed, proceeding without token: {ex.Message}"); + } var path = @event.Activity.GetPath(); Logger.Debug(path); @@ -322,7 +325,7 @@ private async Task Process(ISenderPlugin sender, ActivityEvent @event, if (i + 1 == routes.Count) return data; i++; - var res = await routes[i].Invoke(context); + var res = await routes[i].Invoke(context).ConfigureAwait(false); if (res is not null) data = res; @@ -353,7 +356,7 @@ await Events.Emit( EventType.ActivitySent, new ActivitySentEvent() { Activity = activity }, context.CancellationToken - ); + ).ConfigureAwait(false); } }; @@ -364,7 +367,7 @@ await Events.Emit( EventType.ActivitySent, new ActivitySentEvent() { Activity = activity }, cancellationToken - ); + ).ConfigureAwait(false); }; if (@event.Services is not null) @@ -379,11 +382,11 @@ await Events.Emit( foreach (var plugin in Plugins) { - await plugin.OnActivity(this, sender, @event, cancellationToken); + await plugin.OnActivity(this, sender, @event, cancellationToken).ConfigureAwait(false); } - var res = await Next(context); - await stream.Close(cancellationToken); + var res = await Next(context).ConfigureAwait(false); + await stream.Close(cancellationToken).ConfigureAwait(false); var response = res is Response value ? value @@ -397,7 +400,7 @@ await Events.Emit( EventType.ActivityResponse, new ActivityResponseEvent() { Response = response }, cancellationToken - ); + ).ConfigureAwait(false); return response; } diff --git a/Libraries/Microsoft.Teams.Apps/AppEvents.cs b/Libraries/Microsoft.Teams.Apps/AppEvents.cs index 4dd1636a7..add73af15 100644 --- a/Libraries/Microsoft.Teams.Apps/AppEvents.cs +++ b/Libraries/Microsoft.Teams.Apps/AppEvents.cs @@ -22,7 +22,7 @@ protected async Task OnErrorEvent(IPlugin sender, ErrorEvent @event, Cancellatio if (ex.Request?.Content is not null) { - var content = await ex.Request.Content.ReadAsStringAsync(); + var content = await ex.Request.Content.ReadAsStringAsync().ConfigureAwait(false); Logger.Error(content); } } @@ -30,7 +30,7 @@ protected async Task OnErrorEvent(IPlugin sender, ErrorEvent @event, Cancellatio foreach (var plugin in Plugins) { if (sender.Equals(plugin)) continue; - await plugin.OnError(this, sender, @event, cancellationToken); + await plugin.OnError(this, sender, @event, cancellationToken).ConfigureAwait(false); } } @@ -47,7 +47,7 @@ protected async Task OnActivitySentEvent(ISenderPlugin sender, ActivitySentEvent foreach (var plugin in Plugins) { if (sender.Equals(plugin)) continue; - await plugin.OnActivitySent(this, sender, @event, cancellationToken); + await plugin.OnActivitySent(this, sender, @event, cancellationToken).ConfigureAwait(false); } } @@ -58,7 +58,7 @@ protected async Task OnActivityResponseEvent(ISenderPlugin sender, ActivityRespo foreach (var plugin in Plugins) { if (sender.Equals(plugin)) continue; - await plugin.OnActivityResponse(this, sender, @event, cancellationToken); + await plugin.OnActivityResponse(this, sender, @event, cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Apps/AppPlugins.cs b/Libraries/Microsoft.Teams.Apps/AppPlugins.cs index ec1c63b6b..2d1a023b2 100644 --- a/Libraries/Microsoft.Teams.Apps/AppPlugins.cs +++ b/Libraries/Microsoft.Teams.Apps/AppPlugins.cs @@ -37,11 +37,11 @@ public App AddPlugin(IPlugin plugin) { var eventType = new EventType(name); - await Events.Emit(plugin, $"{attr.Name}.{name}", @event, token); + await Events.Emit(plugin, $"{attr.Name}.{name}", @event, token).ConfigureAwait(false); if (eventType.IsBuiltIn && !eventType.IsStart) { - return await Events.Emit(plugin, name, @event, token); + return await Events.Emit(plugin, name, @event, token).ConfigureAwait(false); } return null; diff --git a/Libraries/Microsoft.Teams.Apps/AppRouting.cs b/Libraries/Microsoft.Teams.Apps/AppRouting.cs index ca061352b..bf05390db 100644 --- a/Libraries/Microsoft.Teams.Apps/AppRouting.cs +++ b/Libraries/Microsoft.Teams.Apps/AppRouting.cs @@ -55,7 +55,7 @@ public App AddController(T controller) where T : class { this.OnEvent(attr.Name, async (plugin, @event, token) => { - await method.InvokeAsync(controller, [plugin, @event]); + await method.InvokeAsync(controller, [plugin, @event]).ConfigureAwait(false); }); Logger.Debug($"'{attr.Name}' event route '{name}.{method.Name}' registered"); @@ -83,7 +83,7 @@ protected async Task OnTokenExchangeActivity(IContext(), Token = res } - ); + ).ConfigureAwait(false); return new Response(HttpStatusCode.OK); } @@ -110,7 +110,7 @@ await Events.Emit( Context = context.ToActivityType() }, context.CancellationToken - ); + ).ConfigureAwait(false); if (ex.StatusCode != HttpStatusCode.NotFound && ex.StatusCode != HttpStatusCode.BadRequest && ex.StatusCode != HttpStatusCode.PreconditionFailed) { @@ -142,7 +142,7 @@ await Events.Emit( UserId = context.Activity.From.Id, ConnectionName = OAuth.DefaultConnectionName, Code = context.Activity.Value.State - }); + }).ConfigureAwait(false); context.UserGraphToken = new JsonWebToken(res); @@ -154,7 +154,7 @@ await Events.Emit( Context = context.ToActivityType(), Token = res } - ); + ).ConfigureAwait(false); return new Response(HttpStatusCode.OK); } catch (HttpException ex) @@ -168,7 +168,7 @@ await Events.Emit( Context = context.ToActivityType() }, context.CancellationToken - ); + ).ConfigureAwait(false); if (ex.StatusCode != HttpStatusCode.NotFound && ex.StatusCode != HttpStatusCode.BadRequest && ex.StatusCode != HttpStatusCode.PreconditionFailed) { @@ -219,7 +219,7 @@ await Events.Emit( Context = context.ToActivityType() }, context.CancellationToken - ); + ).ConfigureAwait(false); return new Response(HttpStatusCode.OK); } @@ -250,7 +250,7 @@ public App Use(Func, Task> handler) { return Use(async (context) => { - await handler(context); + await handler(context).ConfigureAwait(false); return null; }); } diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Client/FunctionContext.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Client/FunctionContext.cs index 4978ace61..4bc5b2d54 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Client/FunctionContext.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Client/FunctionContext.cs @@ -89,12 +89,12 @@ public async Task Send(TActivity activity, CancellationTok Role = Role.User, } ] - }); + }).ConfigureAwait(false); conversationId = res.Id; } - return await app.Send(conversationId, activity, cancellationToken: cancellationToken); + return await app.Send(conversationId, activity, cancellationToken: cancellationToken).ConfigureAwait(false); } public Task Send(string text, CancellationToken cancellationToken = default) diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs index 6828c7d33..4310fbd94 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs @@ -61,8 +61,8 @@ public partial class Context : IContext { public async Task Send(T activity, CancellationToken cancellationToken = default) where T : IActivity { - var res = await Sender.Send(activity, Ref, CancellationToken); - await OnActivitySent(res, ToActivityType()); + var res = await Sender.Send(activity, Ref, cancellationToken).ConfigureAwait(false); + await OnActivitySent(res, ToActivityType()).ConfigureAwait(false); return res; } diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.SignIn.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.SignIn.cs index 4eb6d5134..d07c86e5f 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.SignIn.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.SignIn.cs @@ -64,11 +64,14 @@ public partial class Context : IContext UserId = Activity.From.Id, ChannelId = Activity.ChannelId, ConnectionName = options.ConnectionName ?? ConnectionName, - }); + }).ConfigureAwait(false); return tokenResponse.Token; } - catch { } + catch (Exception ex) + { + Log.Debug($"Existing token retrieval failed, proceeding to token exchange: {ex.Message}"); + } var tokenExchangeState = new Api.TokenExchange.State() { @@ -88,17 +91,17 @@ public partial class Context : IContext IsGroup = false, Bot = Ref.Bot, Members = [Activity.From] - }); + }).ConfigureAwait(false); reference.Conversation.Id = id; reference.Conversation.IsGroup = false; - var oauthCardActivity = await Sender.Send(new MessageActivity(options.OAuthCardText), reference, token); - await OnActivitySent(oauthCardActivity, ToActivityType()); + var oauthCardActivity = await Sender.Send(new MessageActivity(options.OAuthCardText), reference, token).ConfigureAwait(false); + await OnActivitySent(oauthCardActivity, ToActivityType()).ConfigureAwait(false); } var state = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(tokenExchangeState)); - var resource = await api.Bots.SignIn.GetResourceAsync(new() { State = state }); + var resource = await api.Bots.SignIn.GetResourceAsync(new() { State = state }).ConfigureAwait(false); var activity = new MessageActivity(); activity.InputHint = InputHint.AcceptingInput; @@ -119,8 +122,8 @@ public partial class Context : IContext ] }); - var res = await Sender.Send(activity, reference, token); - await OnActivitySent(res, ToActivityType()); + var res = await Sender.Send(activity, reference, token).ConfigureAwait(false); + await OnActivitySent(res, ToActivityType()).ConfigureAwait(false); return null; } @@ -140,13 +143,13 @@ public async Task SignIn(SSOOptions options, CancellationToken cancellationToken IsGroup = false, Bot = Ref.Bot, Members = [Activity.From] - }); + }).ConfigureAwait(false); reference.Conversation.Id = id; reference.Conversation.IsGroup = false; - var oauthCardActivity = await Sender.Send(new MessageActivity(options.OAuthCardText), reference, token); - await OnActivitySent(oauthCardActivity, ToActivityType()); + var oauthCardActivity = await Sender.Send(new MessageActivity(options.OAuthCardText), reference, token).ConfigureAwait(false); + await OnActivitySent(oauthCardActivity, ToActivityType()).ConfigureAwait(false); } var activity = new MessageActivity(); @@ -170,8 +173,8 @@ public async Task SignIn(SSOOptions options, CancellationToken cancellationToken ] }); - var res = await Sender.Send(activity, reference, token); - await OnActivitySent(res, ToActivityType()); + var res = await Sender.Send(activity, reference, token).ConfigureAwait(false); + await OnActivitySent(res, ToActivityType()).ConfigureAwait(false); } public async Task SignOut(string? connectionName = null, CancellationToken cancellationToken = default) @@ -183,7 +186,7 @@ await api.Users.Token.SignOutAsync(new() ChannelId = Ref.ChannelId, UserId = Activity.From.Id, ConnectionName = connectionName ?? ConnectionName, - }); + }).ConfigureAwait(false); } } diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs index 74baa25d1..09810fcfe 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs @@ -140,7 +140,7 @@ public partial class Context(ISenderPlugin sender, IStreamer stream) public CancellationToken CancellationToken { get; set; } internal Func, Task> OnNext { get; set; } = (_) => Task.FromResult(null); - internal ActivitySentHandler OnActivitySent { get; set; } = (_, _) => Task.Run(() => { }); + internal ActivitySentHandler OnActivitySent { get; set; } = (_, _) => Task.CompletedTask; public void Deconstruct(out ILogger log, out ApiClient api, out TActivity activity) { diff --git a/Libraries/Microsoft.Teams.Apps/Events/EventEmitter.cs b/Libraries/Microsoft.Teams.Apps/Events/EventEmitter.cs index 158a5dee1..d2eca5da4 100644 --- a/Libraries/Microsoft.Teams.Apps/Events/EventEmitter.cs +++ b/Libraries/Microsoft.Teams.Apps/Events/EventEmitter.cs @@ -50,7 +50,7 @@ public EventEmitter On(string name, Func { - await handler(plugin, @event, cancellationToken); + await handler(plugin, @event, cancellationToken).ConfigureAwait(false); return null; }); @@ -66,7 +66,7 @@ public EventEmitter On(string name, Func { - var res = await handler(plugin, @event, cancellationToken); + var res = await handler(plugin, @event, cancellationToken).ConfigureAwait(false); return res; }); @@ -80,6 +80,6 @@ public EventEmitter On(string name, Func, Task> Handler { get; set; } public bool Select(IActivity activity) => Selector(activity); - public async Task Invoke(IContext context) => await Handler(context); + public async Task Invoke(IContext context) => await Handler(context).ConfigureAwait(false); } public class AttributeRoute : IRoute diff --git a/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs b/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs index f622b3517..9b4a25d12 100644 --- a/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs +++ b/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. namespace Microsoft.Teams.Common.Extensions; @@ -14,10 +14,10 @@ public static Action Debounce(this Action func, int milliseconds = 300) cancelTokenSource?.Cancel(); cancelTokenSource = new CancellationTokenSource(); - Task.Delay(milliseconds, cancelTokenSource.Token) + _ = Task.Delay(milliseconds, cancelTokenSource.Token) .ContinueWith(t => { - if (t.IsCompleted && !t.IsFaulted) + if (t.IsCompletedSuccessfully) { func(arg); } @@ -34,14 +34,14 @@ public static Action Debounce(this Func func, int milliseconds = 300) cancelTokenSource?.Cancel(); cancelTokenSource = new CancellationTokenSource(); - Task.Delay(milliseconds, cancelTokenSource.Token) + _ = Task.Delay(milliseconds, cancelTokenSource.Token) .ContinueWith(async t => { - if (t.IsCompleted && !t.IsFaulted) + if (t.IsCompletedSuccessfully) { - await func(); + await func().ConfigureAwait(false); } }, TaskScheduler.Default); }; } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Common/Extensions/MethodInfoExtensions.cs b/Libraries/Microsoft.Teams.Common/Extensions/MethodInfoExtensions.cs index 3944a888a..bec1ffa12 100644 --- a/Libraries/Microsoft.Teams.Common/Extensions/MethodInfoExtensions.cs +++ b/Libraries/Microsoft.Teams.Common/Extensions/MethodInfoExtensions.cs @@ -20,7 +20,12 @@ public static class MethodInfoExtensions if (res is Task task) { await task.ConfigureAwait(false); - return task.GetType().GetProperty("Result")?.GetValue(task); + var resultProperty = task.GetType().GetProperty("Result"); + if (resultProperty is not null && task.GetType().IsGenericType) + { + return resultProperty.GetValue(task); + } + return null; } return res; diff --git a/Libraries/Microsoft.Teams.Common/Extensions/TaskExtensions.cs b/Libraries/Microsoft.Teams.Common/Extensions/TaskExtensions.cs index 1ce1db456..ebbbeccec 100644 --- a/Libraries/Microsoft.Teams.Common/Extensions/TaskExtensions.cs +++ b/Libraries/Microsoft.Teams.Common/Extensions/TaskExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. namespace Microsoft.Teams.Common.Extensions; @@ -20,7 +20,7 @@ public static async Task Retry(Func> taskFactory, int max = 3, int { if (max > 0) { - await Task.Delay(delay); + await Task.Delay(delay).ConfigureAwait(false); return await Retry(taskFactory, max - 1, delay * 2).ConfigureAwait(false); } throw new Exception(ex.Message, ex); diff --git a/Libraries/Microsoft.Teams.Common/Http/HttpClient.cs b/Libraries/Microsoft.Teams.Common/Http/HttpClient.cs index f2681f471..563496e05 100644 --- a/Libraries/Microsoft.Teams.Common/Http/HttpClient.cs +++ b/Libraries/Microsoft.Teams.Common/Http/HttpClient.cs @@ -57,15 +57,15 @@ public HttpClient(System.Net.Http.HttpClient client) public async Task> SendAsync(IHttpRequest request, CancellationToken cancellationToken = default) { var httpRequest = CreateRequest(request); - var httpResponse = await _client.SendAsync(httpRequest); - return await CreateResponse(httpResponse, cancellationToken); + var httpResponse = await _client.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + return await CreateResponse(httpResponse, cancellationToken).ConfigureAwait(false); } public async Task> SendAsync(IHttpRequest request, CancellationToken cancellationToken = default) { var httpRequest = CreateRequest(request); - var httpResponse = await _client.SendAsync(httpRequest, cancellationToken); - return await CreateResponse(httpResponse, cancellationToken); + var httpResponse = await _client.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + return await CreateResponse(httpResponse, cancellationToken).ConfigureAwait(false); } public void Dispose() @@ -122,7 +122,7 @@ protected async Task> CreateResponse(HttpResponseMessage r { if (!response.IsSuccessStatusCode) { - var errorBody = await ParseErrorBody(response); + var errorBody = await ParseErrorBody(response).ConfigureAwait(false); throw new HttpException() { @@ -133,7 +133,7 @@ protected async Task> CreateResponse(HttpResponseMessage r }; } - var body = await response.Content.ReadAsStringAsync() ?? throw new ArgumentNullException(); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) ?? throw new ArgumentNullException(); return new HttpResponse() { @@ -147,7 +147,7 @@ protected async Task> CreateResponse { if (!response.IsSuccessStatusCode) { - var errorBody = await ParseErrorBody(response); + var errorBody = await ParseErrorBody(response).ConfigureAwait(false); throw new HttpException() { @@ -158,7 +158,7 @@ protected async Task> CreateResponse }; } - var body = await response.Content.ReadFromJsonAsync(cancellationToken) ?? throw new ArgumentNullException(); + var body = await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false) ?? throw new ArgumentNullException(); return new HttpResponse() { @@ -170,7 +170,7 @@ protected async Task> CreateResponse private async Task ParseErrorBody(HttpResponseMessage response) { - var content = await response.Content.ReadAsStringAsync() ?? throw new ArgumentNullException(); + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false) ?? throw new ArgumentNullException(); object errorBody = content; try diff --git a/Libraries/Microsoft.Teams.Common/Storage/LocalStorage.cs b/Libraries/Microsoft.Teams.Common/Storage/LocalStorage.cs index 2942b8aeb..e2c303fa5 100644 --- a/Libraries/Microsoft.Teams.Common/Storage/LocalStorage.cs +++ b/Libraries/Microsoft.Teams.Common/Storage/LocalStorage.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. namespace Microsoft.Teams.Common.Storage; @@ -57,7 +57,7 @@ public LocalStorage(IDictionary data, int? max) public async Task GetAsync(string key) where T : TValue { - var value = await GetAsync(key); + var value = await GetAsync(key).ConfigureAwait(false); return (T?)value; } @@ -79,7 +79,8 @@ public void Set(string key, TValue value) public Task SetAsync(string key, TValue value) { - return Task.Run(() => Set(key, value)); + Set(key, value); + return Task.CompletedTask; } public void Delete(string key) @@ -94,7 +95,8 @@ public void Delete(string key) public Task DeleteAsync(string key) { - return Task.Run(() => Delete(key)); + Delete(key); + return Task.CompletedTask; } protected bool Hit(string key) diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/ServiceCollection.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/ServiceCollection.cs index 14c276f27..e5bfa05ea 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/ServiceCollection.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/ServiceCollection.cs @@ -99,7 +99,8 @@ public static IServiceCollection AddTeams(this IServiceCollection collection, Fu collection.AddSingleton(provider => provider.GetRequiredService()); collection.AddSingleton(); collection.AddHostedService(); - collection.AddSingleton(provider => factory(provider).GetAwaiter().GetResult()); + // DI factory delegates are synchronous; ConfigureAwait(false) reduces deadlock risk + collection.AddSingleton(provider => factory(provider).ConfigureAwait(false).GetAwaiter().GetResult()); collection.AddSingleton(provider => provider.GetRequiredService().Logger); collection.AddSingleton(provider => provider.GetRequiredService().Storage); collection.AddSingleton(provider => new TeamsLogger(provider.GetRequiredService().Logger)); diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/TeamsService.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/TeamsService.cs index d4ed539a7..39d794da6 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/TeamsService.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/TeamsService.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Extensions.Hosting; @@ -31,7 +31,7 @@ public Task StartAsync(CancellationToken cancellationToken) public async Task StartedAsync(CancellationToken cancellationToken) { - await _app.Start(cancellationToken); + await _app.Start(cancellationToken).ConfigureAwait(false); _logger.LogDebug("Started"); } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.BotBuilder/Controllers/MessageController.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.BotBuilder/Controllers/MessageController.cs index aa1694d16..0168296ea 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.BotBuilder/Controllers/MessageController.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.BotBuilder/Controllers/MessageController.cs @@ -36,7 +36,7 @@ public async Task PostAsync() // Delegate the processing of the HTTP POST to the adapter. // The adapter will invoke the bot. - await _adapter.ProcessAsync(HttpContext.Request, HttpContext.Response, _bot); + await _adapter.ProcessAsync(HttpContext.Request, HttpContext.Response, _bot).ConfigureAwait(false); if (Response.HasStarted) { @@ -44,7 +44,7 @@ public async Task PostAsync() } // Fallback logic use the plugin to process the activity - return await _plugin.Do(HttpContext, _lifetime.ApplicationStopping); + return await _plugin.Do(HttpContext, _lifetime.ApplicationStopping).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/Controllers/ActivityController.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/Controllers/ActivityController.cs index 053d100e0..dc60685a4 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/Controllers/ActivityController.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/Controllers/ActivityController.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Text; @@ -88,7 +88,7 @@ public async Task Create(string conversationId, [FromBody] JsonNode bod Token = token, Activity = activity, Services = HttpContext.RequestServices.CreateAsyncScope().ServiceProvider, - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); return Results.Json(new { id = body["id"] }, statusCode: 201); } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/Controllers/DevToolsController.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/Controllers/DevToolsController.cs index 592ba8485..064d22913 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/Controllers/DevToolsController.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/Controllers/DevToolsController.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Net.WebSockets; @@ -51,18 +51,18 @@ public async Task GetSocket() return; } - using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); var id = Guid.NewGuid().ToString(); var buffer = new byte[1024]; _plugin.Sockets.Add(id, socket); - await _plugin.Sockets.Emit(id, new MetaDataEvent(_plugin.MetaData), _lifetime.ApplicationStopping); + await _plugin.Sockets.Emit(id, new MetaDataEvent(_plugin.MetaData), _lifetime.ApplicationStopping).ConfigureAwait(false); try { while (socket.State.HasFlag(WebSocketState.Open)) { - await socket.ReceiveAsync(buffer, _lifetime.ApplicationStopping); + await socket.ReceiveAsync(buffer, _lifetime.ApplicationStopping).ConfigureAwait(false); } } catch (ConnectionAbortedException) @@ -77,7 +77,7 @@ public async Task GetSocket() { if (socket.IsCloseable()) { - await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, _lifetime.ApplicationStopping); + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, _lifetime.ApplicationStopping).ConfigureAwait(false); } } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs index 309340fec..70bc08ca3 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; @@ -69,7 +69,7 @@ public IApplicationBuilder Configure(IApplicationBuilder builder) { try { - await next(context); + await next(context).ConfigureAwait(false); } catch (Exception ex) { @@ -137,7 +137,7 @@ await Sockets.Emit( @event.Activity.Conversation ), cancellationToken - ); + ).ConfigureAwait(false); } public async Task OnActivitySent(App app, ISenderPlugin sender, ActivitySentEvent @event, CancellationToken cancellationToken = default) @@ -150,7 +150,7 @@ await Sockets.Emit( @event.Activity.Conversation ), cancellationToken - ); + ).ConfigureAwait(false); } public Task OnActivityResponse(App app, ISenderPlugin sender, ActivityResponseEvent @event, CancellationToken cancellationToken = default) diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/WebSocketCollection.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/WebSocketCollection.cs index 8c4961b56..a57ec39be 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/WebSocketCollection.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/WebSocketCollection.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Collections; @@ -65,7 +65,7 @@ public async Task Emit(IEvent @event, CancellationToken cancellationToken = defa foreach (var socket in _store.Values) { - await socket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); + await socket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); } } @@ -81,7 +81,7 @@ public async Task Emit(string key, IEvent @event, CancellationToken cancellation }); var buffer = new ArraySegment(payload, 0, payload.Length); - await socket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); + await socket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs index 449225600..6190eaa83 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Collections.Concurrent; @@ -48,7 +48,7 @@ public void Emit(MessageActivity activity) _queue.Enqueue(activity); _timeout = new Timer(_ => { - _ = Flush(); + _ = FlushSafe(); }, null, 500, Timeout.Infinite); } @@ -63,7 +63,7 @@ public void Emit(TypingActivity activity) _queue.Enqueue(activity); _timeout = new Timer(_ => { - _ = Flush(); + _ = FlushSafe(); }, null, 500, Timeout.Infinite); } @@ -89,7 +89,7 @@ public void Update(string text) if (_result is not null) return _result; while (_id is null || _queue.Count > 0) { - await Task.Delay(50, cancellationToken); + await Task.Delay(50, cancellationToken).ConfigureAwait(false); } if (_text == string.Empty && _attachments.Count == 0) // when only informative updates are present @@ -124,7 +124,7 @@ protected async Task Flush() { if (_queue.Count == 0) return; - await _lock.WaitAsync(); + await _lock.WaitAsync().ConfigureAwait(false); try { @@ -170,7 +170,7 @@ protected async Task Flush() { while (informativeUpdates.TryDequeue(out var typing)) { - await SendActivity(typing); + await SendActivity(typing).ConfigureAwait(false); } } @@ -178,14 +178,14 @@ protected async Task Flush() if (_text != string.Empty) { var toSend = new TypingActivity(_text); - await SendActivity(toSend); + await SendActivity(toSend).ConfigureAwait(false); } if (_queue.Count > 0) { _timeout = new Timer(_ => { - _ = Flush(); + _ = FlushSafe(); }, null, 500, Timeout.Infinite); } @@ -208,5 +208,18 @@ async Task SendActivity(TypingActivity toSend) _lock.Release(); } } + + private async Task FlushSafe() + { + try + { + await Flush().ConfigureAwait(false); + } + catch + { + // Suppress exceptions from fire-and-forget timer callbacks + // to prevent unobserved task exceptions + } + } } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs index bd81beeed..6a80912ff 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Text.Json; @@ -108,22 +108,22 @@ public async Task Send(TActivity activity, Api.Conversatio await client .Conversations .Activities - .UpdateTargetedAsync(reference.Conversation.Id, activity.Id, activity); + .UpdateTargetedAsync(reference.Conversation.Id, activity.Id, activity).ConfigureAwait(false); } else { await client .Conversations .Activities - .UpdateAsync(reference.Conversation.Id, activity.Id, activity); + .UpdateAsync(reference.Conversation.Id, activity.Id, activity).ConfigureAwait(false); } return activity; } var res = isTargeted - ? await client.Conversations.Activities.CreateTargetedAsync(reference.Conversation.Id, activity) - : await client.Conversations.Activities.CreateAsync(reference.Conversation.Id, activity); + ? await client.Conversations.Activities.CreateTargetedAsync(reference.Conversation.Id, activity).ConfigureAwait(false) + : await client.Conversations.Activities.CreateAsync(reference.Conversation.Id, activity).ConfigureAwait(false); #pragma warning restore ExperimentalTeamsTargeted activity.Id = res?.Id; @@ -136,7 +136,7 @@ public IStreamer CreateStream(Api.ConversationReference reference, CancellationT { Send = async activity => { - var res = await Send(activity, reference, cancellationToken); + var res = await Send(activity, reference, cancellationToken).ConfigureAwait(false); return res; } }; @@ -151,7 +151,7 @@ public async Task Do(ActivityEvent @event, CancellationToken cancellat "activity", @event, cancellationToken - ); + ).ConfigureAwait(false); var res = (Response?)@out ?? throw new Exception("expected activity response"); Logger.Debug(res); @@ -165,7 +165,7 @@ await Events( "error", new ErrorEvent() { Exception = ex }, cancellationToken - ); + ).ConfigureAwait(false); return new Response(System.Net.HttpStatusCode.InternalServerError, ex.ToString()); } @@ -177,7 +177,7 @@ public async Task Do(HttpContext httpContext, CancellationToken cancell { var request = httpContext.Request; var token = ExtractToken(request); - var activity = await ParseActivity(request); + var activity = await ParseActivity(request).ConfigureAwait(false); if (activity is null) { @@ -204,7 +204,7 @@ public async Task Do(HttpContext httpContext, CancellationToken cancell Activity = activity, Extra = data, Services = httpContext.RequestServices - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); // convert response metadata to headers foreach (var (key, value) in res.Meta) @@ -229,7 +229,7 @@ await Events( "error", new ErrorEvent() { Exception = ex }, cancellationToken - ); + ).ConfigureAwait(false); return Results.Problem(detail: ex.Message, statusCode: 500); } @@ -252,7 +252,7 @@ public JsonWebToken ExtractToken(HttpRequest httpRequest) } using StreamReader sr = new(httpRequest.Body); - var body = await sr.ReadToEndAsync(); + var body = await sr.ReadToEndAsync().ConfigureAwait(false); Activity? activity = JsonSerializer.Deserialize(body); return activity; diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Functions.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Functions.cs index 60a05e394..ffc5eff65 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Functions.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Functions.cs @@ -51,10 +51,10 @@ public static IApplicationBuilder AddFunction(this IApplicationBuilder bu /// The callback to handle the function public static IApplicationBuilder AddFunction(this IApplicationBuilder builder, string name, Func, Task> handler) { - return builder.AddFunction(name, context => + return builder.AddFunction(name, async context => { - handler(context).ConfigureAwait(false).GetAwaiter(); - return Task.FromResult(null); + await handler(context).ConfigureAwait(false); + return null; }); } @@ -66,10 +66,10 @@ public static IApplicationBuilder AddFunction(this IApplicationBuilder builder, /// The callback to handle the function public static IApplicationBuilder AddFunction(this IApplicationBuilder builder, string name, Func, Task> handler) { - return builder.AddFunction(name, context => + return builder.AddFunction(name, async context => { - handler(context).ConfigureAwait(false).GetAwaiter(); - return Task.FromResult(null); + await handler(context).ConfigureAwait(false); + return null; }); } @@ -101,7 +101,7 @@ public static IApplicationBuilder AddFunction(this IApplicationBuilder builder, /// The callback to handle the function public static IApplicationBuilder AddFunction(this IApplicationBuilder builder, string name, Func, Task> handler) { - return builder.AddFunction(name, context => handler(context).ConfigureAwait(false).GetAwaiter().GetResult()); + return builder.AddFunction(name, async context => await handler(context).ConfigureAwait(false)); } /// @@ -122,19 +122,19 @@ public static IApplicationBuilder AddFunction(this IApplicationBuilder bu if (context.Request.Headers.Authorization.First() is null) { - await Results.Unauthorized().ExecuteAsync(context); + await Results.Unauthorized().ExecuteAsync(context).ConfigureAwait(false); return; } if (!context.Request.Headers.TryGetValue("X-Teams-App-Session-Id", out var appSessionId)) { - await Results.Unauthorized().ExecuteAsync(context); + await Results.Unauthorized().ExecuteAsync(context).ConfigureAwait(false); return; } if (!context.Request.Headers.TryGetValue("X-Teams-Page-Id", out var pageId)) { - await Results.Unauthorized().ExecuteAsync(context); + await Results.Unauthorized().ExecuteAsync(context).ConfigureAwait(false); return; } @@ -190,7 +190,7 @@ public static IApplicationBuilder AddFunction(this IApplicationBuilder bu log.Debug(ctx.Data?.ToString()); var res = handler(ctx); log.Debug(res?.ToString()); - await Results.Json(res).ExecuteAsync(context); + await Results.Json(res).ExecuteAsync(context).ConfigureAwait(false); }).RequireAuthorization(EntraTokenAuthConstants.AuthorizationPolicy); }); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Tabs.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Tabs.cs index ea05a3660..3c25fbda1 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Tabs.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Tabs.cs @@ -53,7 +53,7 @@ IResult OnGet(string path) { endpoints.MapGet($"/tabs/{name}", async context => { - await OnGet("index.html").ExecuteAsync(context); + await OnGet("index.html").ExecuteAsync(context).ConfigureAwait(false); }); endpoints.MapGet($"/tabs/{name}/{{*path}}", async context => @@ -62,11 +62,11 @@ IResult OnGet(string path) if (path is null) { - await Results.NotFound().ExecuteAsync(context); + await Results.NotFound().ExecuteAsync(context).ConfigureAwait(false); return; } - await OnGet(path).ExecuteAsync(context); + await OnGet(path).ExecuteAsync(context).ConfigureAwait(false); }); }); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.cs index fd2f32e97..47d1d4a7b 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Reflection; @@ -75,7 +75,7 @@ public static App UseTeams(this IApplicationBuilder builder, bool routing = true { endpoints.MapPost("/api/messages", async (HttpContext httpContext, CancellationToken cancellationToken) => { - return await aspNetCorePlugin.Do(httpContext, cancellationToken); + return await aspNetCorePlugin.Do(httpContext, cancellationToken).ConfigureAwait(false); }).RequireAuthorization(TeamsTokenAuthConstants.AuthorizationPolicy); } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/Extensions/McpServerBuilder.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/Extensions/McpServerBuilder.cs index f4b7f2c51..b8de401e6 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/Extensions/McpServerBuilder.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/Extensions/McpServerBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Extensions.DependencyInjection; @@ -24,7 +24,7 @@ public static IMcpServerBuilder WithTeamsChatPrompts(this IMcpServerBuilder buil { var mcpPrompt = McpServerPrompt.Create(async (string text) => { - var res = await prompt.Send(UserMessage.Text(text)); + var res = await prompt.Send(UserMessage.Text(text)).ConfigureAwait(false); return ((ModelMessage)res).Content; }, new() { diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs index 2e8177efc..6fe1de75c 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs @@ -1,4 +1,4 @@ -using Json.Schema; +using Json.Schema; using Microsoft.Teams.AI; using Microsoft.Teams.AI.Prompts; @@ -70,7 +70,7 @@ public McpClientPlugin UseMcpServer(string url, McpClientPluginParams? pluginPar public override async Task OnBuildFunctions(IChatPrompt prompt, FunctionCollection functions, CancellationToken cancellationToken = default) { - await FetchToolsIfNeeded(); + await FetchToolsIfNeeded().ConfigureAwait(false); foreach (var entry in _mcpServerParams) { @@ -136,12 +136,12 @@ internal async Task FetchToolsIfNeeded() } try { - await Task.WhenAll(tasks); + await Task.WhenAll(tasks).ConfigureAwait(false); } - catch + catch (Exception ex) { - // Suppress all exceptions, but tasks are still awaited - // Individual task exceptions will be handled below + // Suppress aggregate exception; individual task exceptions are handled below + _logger.Debug($"One or more MCP tool fetch tasks failed: {ex.Message}"); } var results = fetchNeeded.Zip(tasks); @@ -182,8 +182,8 @@ internal async Task FetchToolsIfNeeded() internal async Task> FetchToolsFromServer(Uri url, McpClientPluginParams pluginParams) { IClientTransport transport = CreateTransport(url, pluginParams.Transport, pluginParams.HeadersFactory()); - var client = await McpClientFactory.CreateAsync(transport); - var tools = await client.ListToolsAsync(); + var client = await McpClientFactory.CreateAsync(transport).ConfigureAwait(false); + var tools = await client.ListToolsAsync().ConfigureAwait(false); // Convert MCP tools to our format var mappedTools = tools.Select(t => new McpToolDetails() @@ -226,7 +226,7 @@ internal AI.Function CreateFunctionFromTool(Uri url, McpToolDetails tool, McpCli try { _logger.Debug($"Making call to {url} for tool {tool.Name}"); - string result = await CallMcpTool(url, tool, args.AsReadOnly(), pluginParams); + string result = await CallMcpTool(url, tool, args.AsReadOnly(), pluginParams).ConfigureAwait(false); _logger.Debug($"Received result from {tool.Name}: {result}"); return result; } @@ -242,8 +242,8 @@ internal AI.Function CreateFunctionFromTool(Uri url, McpToolDetails tool, McpCli internal async Task CallMcpTool(Uri url, McpToolDetails tool, IReadOnlyDictionary args, McpClientPluginParams pluginParams) { IClientTransport transport = CreateTransport(url, pluginParams.Transport, pluginParams.HeadersFactory()); - var client = await McpClientFactory.CreateAsync(transport); - var response = await client.CallToolAsync(tool.Name, args); + var client = await McpClientFactory.CreateAsync(transport).ConfigureAwait(false); + var response = await client.CallToolAsync(tool.Name, args).ConfigureAwait(false); if (response.IsError == true) { diff --git a/Tests/Microsoft.Teams.AI.Tests/ChatPluginTests.cs b/Tests/Microsoft.Teams.AI.Tests/ChatPluginTests.cs index 1d8273696..010d6b1a1 100644 --- a/Tests/Microsoft.Teams.AI.Tests/ChatPluginTests.cs +++ b/Tests/Microsoft.Teams.AI.Tests/ChatPluginTests.cs @@ -49,7 +49,7 @@ public async Task Test_ChatPlugin_HooksCalled(string hookName) chatPlugin.Verify(p => p.OnBuildFunctions(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); break; case "OnBuildInstructions": - chatPlugin.Verify(p => p.OnBuildInstructions(It.IsAny>(), It.IsAny()), Times.Once); + chatPlugin.Verify(p => p.OnBuildInstructions(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); break; } diff --git a/Tests/Microsoft.Teams.AI.Tests/Utils/TestChatPlugin.cs b/Tests/Microsoft.Teams.AI.Tests/Utils/TestChatPlugin.cs index f2f6c75f6..58390b731 100644 --- a/Tests/Microsoft.Teams.AI.Tests/Utils/TestChatPlugin.cs +++ b/Tests/Microsoft.Teams.AI.Tests/Utils/TestChatPlugin.cs @@ -30,7 +30,7 @@ public virtual Task OnBuildFunctions(IChatPrompt OnBuildInstructions(IChatPrompt prompt, DeveloperMessage? instructions) + public virtual Task OnBuildInstructions(IChatPrompt prompt, DeveloperMessage? instructions, CancellationToken cancellationToken = default) { return Task.FromResult(instructions); } From 8f62e8c86cbeda26c4a776075201199d3172c418 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 26 Mar 2026 09:51:48 -0700 Subject: [PATCH 2/5] Address PR review feedback on async fixes - FlushSafe: catch specific Exception type with explanatory comment about transient flush errors - McpClientPlugin: pass full Exception object to logger instead of just ex.Message, preserving stack traces for diagnostics - ChatPrompt.Errors: avoid async void event handler by using fire-and-forget pattern instead of async lambda - IStream: use default interface method for EmitAsync to avoid breaking external implementers; mark sync Emit as Obsolete - ActionExtensions: replace ContinueWith(async ...) with proper async local function to avoid Task unwrapping issues Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Prompts/ChatPrompt/ChatPrompt.Errors.cs | 5 ++++- Libraries/Microsoft.Teams.AI/Stream.cs | 11 +++++++--- .../Extensions/ActionExtensions.cs | 22 ++++++++++++------- .../AspNetCorePlugin.Stream.cs | 6 +++-- .../McpClientPlugin.cs | 2 +- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs index e4db2e3f5..582c020a9 100644 --- a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs +++ b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs @@ -13,7 +13,10 @@ public IChatPrompt OnError(Action onError) public IChatPrompt OnError(Func onError) { - ErrorEvent += async (_, ex) => await onError(ex).ConfigureAwait(false); + ErrorEvent += (_, ex) => + { + _ = onError(ex).ConfigureAwait(false); + }; return this; } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.AI/Stream.cs b/Libraries/Microsoft.Teams.AI/Stream.cs index 4e198fa02..1064c1239 100644 --- a/Libraries/Microsoft.Teams.AI/Stream.cs +++ b/Libraries/Microsoft.Teams.AI/Stream.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. namespace Microsoft.Teams.AI; @@ -24,7 +24,11 @@ public interface IStream /// emit a text chunk asynchronously /// /// the text chunk - public Task EmitAsync(string text); + public Task EmitAsync(string text) + { + Emit(text); + return Task.CompletedTask; + } } /// @@ -32,6 +36,7 @@ public interface IStream /// public class Stream(OnStreamChunk onChunk) : IStream { + [Obsolete("Use EmitAsync instead to avoid sync-over-async blocking.")] public void Emit(string text) { EmitAsync(text).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -41,4 +46,4 @@ public Task EmitAsync(string text) { return onChunk(text); } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs b/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs index 9b4a25d12..8e5deec5d 100644 --- a/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs +++ b/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs @@ -34,14 +34,20 @@ public static Action Debounce(this Func func, int milliseconds = 300) cancelTokenSource?.Cancel(); cancelTokenSource = new CancellationTokenSource(); - _ = Task.Delay(milliseconds, cancelTokenSource.Token) - .ContinueWith(async t => - { - if (t.IsCompletedSuccessfully) - { - await func().ConfigureAwait(false); - } - }, TaskScheduler.Default); + _ = DebounceCore(func, milliseconds, cancelTokenSource.Token); }; + + static async Task DebounceCore(Func func, int milliseconds, CancellationToken token) + { + try + { + await Task.Delay(milliseconds, token).ConfigureAwait(false); + await func().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Debounce was cancelled by a newer invocation + } + } } } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs index 6190eaa83..155c3aefa 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs @@ -215,10 +215,12 @@ private async Task FlushSafe() { await Flush().ConfigureAwait(false); } - catch + catch (Exception) { // Suppress exceptions from fire-and-forget timer callbacks - // to prevent unobserved task exceptions + // to prevent unobserved task exceptions. + // Flush errors are transient (e.g. network hiccups) and the + // stream will retry on the next timer tick or Close(). } } } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs index 6fe1de75c..e6ae8be69 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs @@ -141,7 +141,7 @@ internal async Task FetchToolsIfNeeded() catch (Exception ex) { // Suppress aggregate exception; individual task exceptions are handled below - _logger.Debug($"One or more MCP tool fetch tasks failed: {ex.Message}"); + _logger.Debug("One or more MCP tool fetch tasks failed", ex); } var results = fetchNeeded.Zip(tasks); From 1c5a932c1d2ead62673cc5223c653023b151edca Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 26 Mar 2026 10:24:31 -0700 Subject: [PATCH 3/5] Address second round of PR review feedback - FlushSafe: reschedule retry timer on failure to prevent Close() from spinning forever when _id is null after a transient send error - App.cs, Context.SignIn.cs: pass full Exception object to logger instead of interpolated ex.Message, preserving stack traces - ApplicationBuilder.Functions: use FirstOrDefault() instead of First() for Authorization header to avoid throwing on missing header - ChatPrompt.Errors: observe fire-and-forget task faults via ContinueWith and log them, instead of silently discarding - ActionExtensions: catch non-cancellation exceptions in DebounceCore to prevent UnobservedTaskException; dispose previous CTS to avoid resource leaks Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Prompts/ChatPrompt/ChatPrompt.Errors.cs | 12 +++++++++--- Libraries/Microsoft.Teams.Apps/App.cs | 2 +- .../Microsoft.Teams.Apps/Contexts/Context.SignIn.cs | 2 +- .../Extensions/ActionExtensions.cs | 7 +++++++ .../AspNetCorePlugin.Stream.cs | 13 +++++++++---- .../Extensions/ApplicationBuilder.Functions.cs | 2 +- 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs index 582c020a9..3f34d82ca 100644 --- a/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs +++ b/Libraries/Microsoft.Teams.AI/Prompts/ChatPrompt/ChatPrompt.Errors.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. namespace Microsoft.Teams.AI.Prompts; @@ -15,8 +15,14 @@ public IChatPrompt OnError(Func onError) { ErrorEvent += (_, ex) => { - _ = onError(ex).ConfigureAwait(false); + onError(ex).ContinueWith(t => + { + if (t.IsFaulted) + { + Logger.Error(t.Exception); + } + }, TaskScheduler.Default); }; return this; } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index 45e66200d..7f7374a96 100644 --- a/Libraries/Microsoft.Teams.Apps/App.cs +++ b/Libraries/Microsoft.Teams.Apps/App.cs @@ -302,7 +302,7 @@ private async Task Process(ISenderPlugin sender, ActivityEvent @event, } catch (Exception ex) { - Logger.Debug($"Token retrieval failed, proceeding without token: {ex.Message}"); + Logger.Debug("Token retrieval failed, proceeding without token", ex); } var path = @event.Activity.GetPath(); diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.SignIn.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.SignIn.cs index d07c86e5f..4ff54b3da 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.SignIn.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.SignIn.cs @@ -70,7 +70,7 @@ public partial class Context : IContext } catch (Exception ex) { - Log.Debug($"Existing token retrieval failed, proceeding to token exchange: {ex.Message}"); + Log.Debug("Existing token retrieval failed, proceeding to token exchange", ex); } var tokenExchangeState = new Api.TokenExchange.State() diff --git a/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs b/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs index 8e5deec5d..7f3248883 100644 --- a/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs +++ b/Libraries/Microsoft.Teams.Common/Extensions/ActionExtensions.cs @@ -12,6 +12,7 @@ public static Action Debounce(this Action func, int milliseconds = 300) return arg => { cancelTokenSource?.Cancel(); + cancelTokenSource?.Dispose(); cancelTokenSource = new CancellationTokenSource(); _ = Task.Delay(milliseconds, cancelTokenSource.Token) @@ -32,6 +33,7 @@ public static Action Debounce(this Func func, int milliseconds = 300) return () => { cancelTokenSource?.Cancel(); + cancelTokenSource?.Dispose(); cancelTokenSource = new CancellationTokenSource(); _ = DebounceCore(func, milliseconds, cancelTokenSource.Token); @@ -48,6 +50,11 @@ static async Task DebounceCore(Func func, int milliseconds, CancellationTo { // Debounce was cancelled by a newer invocation } + catch (Exception) + { + // Observe exception to prevent UnobservedTaskException. + // Callers use fire-and-forget; there is no upstream to propagate to. + } } } } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs index 155c3aefa..7b06d5b0e 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs @@ -217,10 +217,15 @@ private async Task FlushSafe() } catch (Exception) { - // Suppress exceptions from fire-and-forget timer callbacks - // to prevent unobserved task exceptions. - // Flush errors are transient (e.g. network hiccups) and the - // stream will retry on the next timer tick or Close(). + // Reschedule a retry so Close() doesn't spin forever + // waiting for _id to be set after a transient send failure. + if (_queue.Count > 0 || _id is null && _count > 0) + { + _timeout = new Timer(_ => + { + _ = FlushSafe(); + }, null, 1000, Timeout.Infinite); + } } } } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Functions.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Functions.cs index ffc5eff65..21606de1f 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Functions.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/ApplicationBuilder.Functions.cs @@ -120,7 +120,7 @@ public static IApplicationBuilder AddFunction(this IApplicationBuilder bu var app = context.RequestServices.GetRequiredService(); var log = app.Logger.Child("functions").Child(name); - if (context.Request.Headers.Authorization.First() is null) + if (context.Request.Headers.Authorization.FirstOrDefault() is null) { await Results.Unauthorized().ExecuteAsync(context).ConfigureAwait(false); return; From 9ba530e20ad92c1799f90a18944086152a098748 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:35:01 -0700 Subject: [PATCH 4/5] Address async review feedback: Emit indirection, FlushSafe logging, CancellationToken propagation in McpClientPlugin (#403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues flagged in review of the async anti-pattern fixes PR were left unaddressed. ## Changes - **`Stream.Emit`** – Remove unnecessary `EmitAsync` indirection; call `onChunk(text).GetAwaiter().GetResult()` directly. `ConfigureAwait(false)` on a synchronously-blocked call has no effect. - **`AspNetCorePlugin.Stream` / `FlushSafe`** – Add optional `ILogger? Logger` property to the nested `Stream` class. `CreateStream` wires it to `Logger.Child("stream")`. `FlushSafe` now logs at `Warn` with the full exception before scheduling the retry timer, making send failures diagnosable in production. - **`McpClientPlugin`** – Thread `CancellationToken` from `OnBuildFunctions` through the full discovery call chain: ``` OnBuildFunctions(cancellationToken) → FetchToolsIfNeeded(cancellationToken) → FetchToolsFromServer(..., cancellationToken) → McpClientFactory.CreateAsync(..., cancellationToken: cancellationToken) → client.ListToolsAsync(cancellationToken: cancellationToken) ``` Previously the token was accepted but never forwarded, so callers could not cancel in-flight tool discovery. --- ⌨️ Start Copilot coding agent tasks without leaving your editor — available in [VS Code](https://gh.io/cca-vs-code-docs), [Visual Studio](https://gh.io/cca-visual-studio-docs), [JetBrains IDEs](https://gh.io/cca-jetbrains-docs) and [Eclipse](https://gh.io/cca-eclipse-docs). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rido-min <14916339+rido-min@users.noreply.github.com> --- Libraries/Microsoft.Teams.AI/Stream.cs | 2 +- .../AspNetCorePlugin.Stream.cs | 5 ++++- .../AspNetCorePlugin.cs | 3 ++- .../McpClientPlugin.cs | 12 ++++++------ 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Libraries/Microsoft.Teams.AI/Stream.cs b/Libraries/Microsoft.Teams.AI/Stream.cs index 1064c1239..d758069d2 100644 --- a/Libraries/Microsoft.Teams.AI/Stream.cs +++ b/Libraries/Microsoft.Teams.AI/Stream.cs @@ -39,7 +39,7 @@ public class Stream(OnStreamChunk onChunk) : IStream [Obsolete("Use EmitAsync instead to avoid sync-over-async blocking.")] public void Emit(string text) { - EmitAsync(text).ConfigureAwait(false).GetAwaiter().GetResult(); + onChunk(text).GetAwaiter().GetResult(); } public Task EmitAsync(string text) diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs index 7b06d5b0e..4c19075e6 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs @@ -7,6 +7,7 @@ using Microsoft.Teams.Api.Activities; using Microsoft.Teams.Api.Entities; using Microsoft.Teams.Apps.Plugins; +using Microsoft.Teams.Common.Logging; using static Microsoft.Teams.Common.Extensions.TaskExtensions; @@ -21,6 +22,7 @@ public class Stream : IStreamer public int Sequence => _index; public required Func> Send { get; set; } + public ILogger? Logger { get; set; } public event IStreamer.OnChunkHandler OnChunk = (_) => { }; protected int _index = 1; @@ -215,8 +217,9 @@ private async Task FlushSafe() { await Flush().ConfigureAwait(false); } - catch (Exception) + catch (Exception ex) { + Logger?.Warn("Stream flush failed; will retry if there is pending state.", ex); // Reschedule a retry so Close() doesn't spin forever // waiting for _id to be set after a transient send failure. if (_queue.Count > 0 || _id is null && _count > 0) diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs index 6a80912ff..33c41c0ee 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs @@ -138,7 +138,8 @@ public IStreamer CreateStream(Api.ConversationReference reference, CancellationT { var res = await Send(activity, reference, cancellationToken).ConfigureAwait(false); return res; - } + }, + Logger = Logger.Child("stream") }; } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs index e6ae8be69..18ff865d7 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs @@ -70,7 +70,7 @@ public McpClientPlugin UseMcpServer(string url, McpClientPluginParams? pluginPar public override async Task OnBuildFunctions(IChatPrompt prompt, FunctionCollection functions, CancellationToken cancellationToken = default) { - await FetchToolsIfNeeded().ConfigureAwait(false); + await FetchToolsIfNeeded(cancellationToken).ConfigureAwait(false); foreach (var entry in _mcpServerParams) { @@ -100,7 +100,7 @@ public override async Task OnBuildFunctions(IChatP /// /// Checks if cached values have expired or if tools have never been fetched. Performs parallel fetching for efficiency. /// - internal async Task FetchToolsIfNeeded() + internal async Task FetchToolsIfNeeded(CancellationToken cancellationToken = default) { var fetchNeeded = new List>(); @@ -132,7 +132,7 @@ internal async Task FetchToolsIfNeeded() { string url = entry.Key; McpClientPluginParams pluginParams = entry.Value; - tasks.Add(FetchToolsFromServer(new Uri(url), pluginParams)); + tasks.Add(FetchToolsFromServer(new Uri(url), pluginParams, cancellationToken)); } try { @@ -179,11 +179,11 @@ internal async Task FetchToolsIfNeeded() } } - internal async Task> FetchToolsFromServer(Uri url, McpClientPluginParams pluginParams) + internal async Task> FetchToolsFromServer(Uri url, McpClientPluginParams pluginParams, CancellationToken cancellationToken = default) { IClientTransport transport = CreateTransport(url, pluginParams.Transport, pluginParams.HeadersFactory()); - var client = await McpClientFactory.CreateAsync(transport).ConfigureAwait(false); - var tools = await client.ListToolsAsync().ConfigureAwait(false); + var client = await McpClientFactory.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); + var tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); // Convert MCP tools to our format var mappedTools = tools.Select(t => new McpToolDetails() From 8d8dd0945a3477055562a0fa153aa832c597acef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 03:29:59 +0000 Subject: [PATCH 5/5] Resolve merge conflicts with latest main Agent-Logs-Url: https://github.com/microsoft/teams.net/sessions/0884830b-36b2-44a7-ac0e-05f46dbf2b4f Co-authored-by: rido-min <14916339+rido-min@users.noreply.github.com> --- .azdo/cd-core.yaml | 18 +- .azdo/ci.yaml | 5 +- .azdo/publish-preview.yaml | 158 - .azdo/publish.yaml | 227 + .azdo/publish.yml | 87 - .devcontainer/devcontainer.json | 13 +- .github/ISSUE_TEMPLATE/bug_report.yml | 72 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/workflows/build-test-lint.yml | 3 +- .github/workflows/core-ci.yaml | 38 + .gitignore | 1 + CONTRIBUTING.md | 5 + .../Activities/Activity.cs | 96 +- .../Activities/Invokes/MessageActivity.cs | 9 + .../Invokes/Messages/FetchTaskActivity.cs | 98 + .../Activities/Message/MessageActivity.cs | 63 +- .../Auth/ClientCredentials.cs | 5 +- .../Auth/CloudEnvironment.cs | 171 + Libraries/Microsoft.Teams.Api/ChannelData.cs | 16 +- .../Microsoft.Teams.Api/Clients/ApiClient.cs | 4 + .../Clients/BotSignInClient.cs | 6 +- .../Clients/BotTokenClient.cs | 8 +- .../Clients/UserTokenClient.cs | 14 +- Libraries/Microsoft.Teams.Api/Conversation.cs | 25 + .../Microsoft.Teams.Api/Entities/Entity.cs | 11 + .../Entities/QuotedReplyEntity.cs | 46 + Libraries/Microsoft.Teams.Api/FeedbackLoop.cs | 44 + .../Activities/Activity.cs | 8 + .../Activities/CommandActivity.cs | 1 + .../Activities/CommandResultActivity.cs | 1 + .../Conversations/ChannelCreatedActivity.cs | 1 + .../Conversations/ChannelDeletedActivity.cs | 1 + .../ChannelMemberAddedActivity.cs | 1 + .../ChannelMemberRemovedActivity.cs | 1 + .../Conversations/ChannelRenamedActivity.cs | 1 + .../Conversations/ChannelRestoredActivity.cs | 1 + .../Conversations/ChannelSharedActivity.cs | 1 + .../Conversations/ChannelUnsharedActivity.cs | 1 + .../Conversations/ConversationEndActivity.cs | 1 + .../ConversationUpdateActivity.cs | 1 + .../Conversations/MembersAddedActivity.cs | 1 + .../Conversations/MembersRemovedActivity.cs | 1 + .../Conversations/TeamArchivedActivity.cs | 1 + .../Conversations/TeamDeletedActivity.cs | 2 + .../Conversations/TeamRenamedActivity.cs | 1 + .../Conversations/TeamRestoredActivity.cs | 1 + .../Conversations/TeamUnArchivedActivity.cs | 1 + .../Activities/Events/EventActivity.cs | 1 + .../Activities/Events/MeetingEndActivity.cs | 1 + .../Activities/Events/MeetingJoinActivity.cs | 1 + .../Activities/Events/MeetingLeaveActivity.cs | 1 + .../Activities/Events/MeetingStartActivity.cs | 1 + .../Activities/Events/ReadReceiptActivity.cs | 1 + .../Activities/Installs/InstallActivity.cs | 1 + .../Installs/InstallUpdateActivity.cs | 1 + .../Activities/Installs/UnInstallActivity.cs | 1 + .../Invokes/AdaptiveCards/ActionActivity.cs | 3 + .../Invokes/Configs/FetchActivity.cs | 3 + .../Invokes/Configs/SubmitActivity.cs | 3 + .../Invokes/ExecuteActionActivity.cs | 2 + .../Activities/Invokes/FileConsentActivity.cs | 2 + .../Activities/Invokes/HandoffActivity.cs | 2 + .../Activities/Invokes/InvokeActivity.cs | 3 + .../AnonQueryLinkActivity.cs | 3 + .../CardButtonClickedActivity.cs | 1 + .../MessageExtensions/FetchTaskActivity.cs | 3 + .../MessageExtensions/QueryActivity.cs | 3 + .../MessageExtensions/QueryLinkActivity.cs | 3 + .../QuerySettingsUrlActivity.cs | 3 + .../MessageExtensions/SelectItemActivity.cs | 3 + .../MessageExtensions/SettingsActivity.cs | 1 + .../MessageExtensions/SubmitActionActivity.cs | 3 + .../Invokes/Messages/FeedbackActivity.cs | 1 + .../Invokes/Messages/FetchTaskActivity.cs | 37 + .../Invokes/Messages/SubmitActionActivity.cs | 2 + .../Invokes/Search/AnswerSearchActivity.cs | 3 + .../Invokes/Search/SearchActivity.cs | 3 + .../Invokes/Search/TypeaheadSearchActivity.cs | 3 + .../Invokes/SignIn/FailureActivity.cs | 3 + .../Invokes/SignIn/TokenExchangeActivity.cs | 4 + .../Invokes/SignIn/VerifyStateAcitivity.cs | 3 + .../Activities/Invokes/Tabs/FetchActivity.cs | 3 + .../Activities/Invokes/Tabs/SubmitActivity.cs | 3 + .../Activities/Invokes/Tasks/FetchActivity.cs | 3 + .../Invokes/Tasks/SubmitActivity.cs | 3 + .../Activities/Messages/MessageActivity.cs | 3 + .../Messages/MessageDeleteActivity.cs | 1 + .../Messages/MessageReactionActivity.cs | 3 + .../Messages/MessageUpdateActivity.cs | 1 + .../Activities/TypingActivity.cs | 1 + Libraries/Microsoft.Teams.Apps/App.cs | 115 +- Libraries/Microsoft.Teams.Apps/AppBuilder.cs | 18 + Libraries/Microsoft.Teams.Apps/AppEvents.cs | 11 +- Libraries/Microsoft.Teams.Apps/AppOptions.cs | 9 + Libraries/Microsoft.Teams.Apps/AppRouting.cs | 22 +- .../Contexts/Context.Send.cs | 69 +- .../Actions/IMBackAction.cs | 4 + .../Actions/InvokeAction.cs | 5 +- .../Actions/MessageBackAction.cs | 4 + .../Actions/SignInAction.cs | 4 + .../Actions/TaskFetchAction.cs | 4 + Libraries/Microsoft.Teams.Cards/Core.cs | 15383 ++++++++++------ .../Utilities/OpenDialogData.cs | 34 + .../Utilities/SubmitData.cs | 32 + .../TeamsSettings.cs | 61 +- .../Microsoft.Teams.Extensions.Graph.csproj | 1 + .../HostApplicationBuilder.cs | 22 +- .../DevToolsPlugin.cs | 11 + .../AspNetCorePlugin.Stream.cs | 13 +- .../Extensions/HostApplicationBuilder.cs | 3 +- .../Extensions/TeamsValidationSettings.cs | 35 +- .../version.json | 2 +- .../version.json | 2 +- README.md | 8 +- RELEASE.md | 89 +- .../Samples.AI/Handlers/FeedbackHandler.cs | 45 +- Samples/Samples.AI/Program.cs | 11 +- Samples/Samples.AI/Samples.AI.csproj | 1 + Samples/Samples.Cards/Program.cs | 10 +- Samples/Samples.Dialogs/Program.cs | 36 +- Samples/Samples.Quoting/Program.cs | 135 + .../Properties/launchSettings.TEMPLATE.json | 17 + Samples/Samples.Quoting/README.md | 100 + .../Samples.Quoting/Samples.Quoting.csproj | 25 + Samples/Samples.Quoting/appsettings.json | 18 + Samples/Samples.Tab/Web/package-lock.json | 144 +- Samples/Samples.Tab/Web/package.json | 10 +- Samples/Samples.TargetedMessages/Program.cs | 2 +- Samples/Samples.Threading/Program.cs | 80 + Samples/Samples.Threading/README.md | 39 + .../Samples.Threading.csproj | 19 + Samples/Samples.Threading/appsettings.json | 17 + .../Message/MessageActivityTests.cs | 23 +- .../Auth/CloudEnvironmentTests.cs | 195 + .../Clients/BotTokenClientTests.cs | 45 + .../ConversationTests.cs | 70 + .../Entities/QuotedReplyEntityTests.cs | 253 + .../Json/Entities/QuotedReplyEntity.json | 12 + .../Microsoft.Teams.Api.Tests.csproj | 2 +- .../Messages/FetchTaskActivityTests.cs | 142 + Tests/Microsoft.Teams.Apps.Tests/AppTests.cs | 67 + .../Contexts/ContextQuotedReplyTests.cs | 232 + .../Microsoft.Teams.Apps.Tests.csproj | 2 +- .../AdaptiveCardsTest.cs | 117 +- .../AspNetCorePluginStreamTests.cs | 88 + .../TeamsValidationSettingsTests.cs | 107 + core/.editorconfig | 40 + core/.gitignore | 9 + core/README.md | 126 + core/bot_icon.png | Bin 0 -> 1899 bytes core/core.slnx | 52 + core/docs/Activity-Design.md | 362 + core/docs/ApiClient-Design.md | 249 + core/docs/Architecture.md | 938 + core/docs/CompatTeamsInfo-API-Mapping.md | 161 + core/docs/Core-Compat-PackageDependencies.md | 227 + core/docs/CreateConversation-API-Behavior.md | 432 + core/docs/MigrationGuide.md | 368 + core/docs/ReduceBreakingChangesPlan.md | 530 + .../design-decouple-cancellation-token.md | 104 + core/docs/sso/OAuthFlow-Design.md | 700 + ...wbot-trace-2026-04-22-sequence-diagrams.md | 167 + .../oauthflowbot-trace-2026-04-22-summary.md | 355 + .../security-audit-oauthflow-2026-04-22.md | 225 + .../sso-trace-2026-04-22-sequence-diagrams.md | 161 + core/docs/sso/sso-trace-2026-04-22-summary.md | 347 + core/samples/AFBot/AFBot.csproj | 22 + core/samples/AFBot/DropTypingMiddleware.cs | 16 + core/samples/AFBot/Program.cs | 68 + core/samples/AFBot/appsettings.json | 10 + core/samples/AllFeatures/AllFeatures.csproj | 13 + core/samples/AllFeatures/AllFeatures.http | 6 + core/samples/AllFeatures/Program.cs | 28 + core/samples/AllFeatures/appsettings.json | 9 + .../AllInvokesBot/AllInvokesBot.csproj | 13 + core/samples/AllInvokesBot/Cards.cs | 140 + core/samples/AllInvokesBot/Program.cs | 289 + core/samples/AllInvokesBot/README.md | 52 + core/samples/AllInvokesBot/appsettings.json | 9 + core/samples/AllInvokesBot/manifest.json | 61 + core/samples/CompatBot/Cards.cs | 62 + core/samples/CompatBot/CompatBot.csproj | 13 + core/samples/CompatBot/EchoBot.cs | 230 + core/samples/CompatBot/MyCompatMiddleware.cs | 20 + core/samples/CompatBot/Program.cs | 56 + core/samples/CompatBot/appsettings.json | 10 + .../CompatProactive/CompatProactive.csproj | 20 + .../CompatProactive/ProactiveWorker.cs | 34 + core/samples/CompatProactive/Program.cs | 14 + core/samples/CompatProactive/appsettings.json | 8 + core/samples/CoreBot/CoreBot.csproj | 13 + core/samples/CoreBot/Program.cs | 31 + core/samples/CoreBot/appsettings.json | 10 + .../CustomHosting/CustomHosting.csproj | 13 + core/samples/CustomHosting/MyTeamsBotApp.cs | 21 + core/samples/CustomHosting/Program.cs | 17 + core/samples/CustomHosting/appsettings.json | 9 + core/samples/Directory.Build.props | 6 + core/samples/MeetingsBot/MeetingsBot.csproj | 13 + core/samples/MeetingsBot/Program.cs | 68 + core/samples/MeetingsBot/README.md | 51 + core/samples/MeetingsBot/appsettings.json | 9 + core/samples/MessageExtensionBot/Cards.cs | 117 + .../MessageExtensionBot.csproj | 13 + core/samples/MessageExtensionBot/Program.cs | 264 + core/samples/MessageExtensionBot/README.md | 55 + .../MessageExtensionBot/appsettings.json | 9 + .../samples/MessageExtensionBot/manifest.json | 123 + core/samples/OAuthFlowBot/OAuthFlowBot.csproj | 13 + core/samples/OAuthFlowBot/Program.cs | 211 + core/samples/OAuthFlowBot/appsettings.json | 9 + core/samples/PABot/AdapterWithErrorHandler.cs | 65 + core/samples/PABot/Bots/DialogBot.cs | 80 + core/samples/PABot/Bots/EchoBot.cs | 22 + core/samples/PABot/Bots/SsoBot.cs | 394 + core/samples/PABot/Bots/TeamsBot.cs | 219 + core/samples/PABot/Dialogs/LogoutDialog.cs | 101 + core/samples/PABot/Dialogs/MainDialog.cs | 319 + core/samples/PABot/InitCompatAdapter.cs | 300 + core/samples/PABot/PABot.csproj | 29 + core/samples/PABot/PACustomAuthHandler.cs | 76 + core/samples/PABot/Program.cs | 45 + .../Properties/launchSettings.TEMPLATE.json | 29 + .../PABot/RoutedTokenAcquisitionService.cs | 127 + core/samples/PABot/SimpleGraphClient.cs | 162 + core/samples/PABot/appsettings.json | 2 + core/samples/Proactive/Proactive.csproj | 17 + core/samples/Proactive/Program.cs | 13 + core/samples/Proactive/Worker.cs | 33 + core/samples/Proactive/appsettings.json | 8 + core/samples/Quoting/Program.cs | 108 + core/samples/Quoting/Quoting.csproj | 14 + core/samples/Quoting/README.md | 29 + core/samples/Quoting/appsettings.json | 9 + core/samples/SsoBot/Program.cs | 148 + core/samples/SsoBot/SsoBot.csproj | 13 + core/samples/SsoBot/appsettings.json | 9 + core/samples/StreamingBot/Program.cs | 94 + core/samples/StreamingBot/StreamingBot.csproj | 17 + core/samples/StreamingBot/appsettings.json | 13 + core/samples/TabApp/Body.cs | 13 + core/samples/TabApp/Program.cs | 92 + core/samples/TabApp/README.md | 109 + core/samples/TabApp/TabApp.csproj | 18 + core/samples/TabApp/Web/index.html | 12 + core/samples/TabApp/Web/package-lock.json | 1897 ++ core/samples/TabApp/Web/package.json | 22 + core/samples/TabApp/Web/src/App.css | 161 + core/samples/TabApp/Web/src/App.tsx | 159 + core/samples/TabApp/Web/src/main.tsx | 10 + core/samples/TabApp/Web/tsconfig.json | 21 + core/samples/TabApp/Web/vite.config.ts | 12 + core/samples/TabApp/appsettings.json | 9 + core/samples/TeamsBot/Cards.cs | 1048 ++ core/samples/TeamsBot/GlobalSuppressions.cs | 8 + core/samples/TeamsBot/Program.cs | 452 + .../Properties/launchSettings.TEMPLATE.json | 49 + core/samples/TeamsBot/TeamsBot.csproj | 14 + .../TeamsBot/WelcomeMessageMiddleware.cs | 57 + core/samples/TeamsBot/appsettings.json | 9 + core/samples/TeamsChannelBot/Program.cs | 138 + core/samples/TeamsChannelBot/README.md | 55 + .../TeamsChannelBot/TeamsChannelBot.csproj | 13 + core/samples/TeamsChannelBot/appsettings.json | 9 + core/src/Directory.Build.props | 34 + core/src/Directory.Build.targets | 10 + .../ActivitySchemaMapper.cs | 254 + .../CompatConnectorClient.cs | 43 + .../CompatConversations.cs | 433 + .../CompatHostingExtensions.cs | 49 + .../CompatTeamsInfo.Models.cs | 83 + .../CompatUserTokenClient.cs | 174 + .../InternalsVisibleTo.cs | 8 + .../Microsoft.Teams.Apps.BotBuilder/Log.cs | 28 + .../Microsoft.Teams.Apps.BotBuilder.csproj | 14 + .../Microsoft.Teams.Apps.BotBuilder/README.md | 146 + .../TeamsApiClient.cs | 775 + .../TeamsBotAdapter.cs | 179 + .../TeamsBotFrameworkHttpAdapter.cs | 133 + .../Api/Clients/ActivityClient.cs | 105 + .../Api/Clients/ApiClient.cs | 162 + .../Api/Clients/BotClient.cs | 22 + .../Api/Clients/BotSignInClient.cs | 38 + .../Api/Clients/BotTokenClient.cs | 25 + .../Api/Clients/ConversationApiClient.cs | 55 + .../Api/Clients/MeetingClient.cs | 146 + .../Api/Clients/MemberClient.cs | 48 + .../Api/Clients/ReactionClient.cs | 51 + .../Api/Clients/TeamClient.cs | 52 + .../Api/Clients/UserClient.cs | 22 + .../Api/Clients/UserTokenApiClient.cs | 62 + core/src/Microsoft.Teams.Apps/AppBuilder.cs | 36 + core/src/Microsoft.Teams.Apps/Context.cs | 294 + .../src/Microsoft.Teams.Apps/ContextLogger.cs | 73 + .../GlobalSuppressions.cs | 22 + .../AdaptiveCardHandler.ActionValue.cs | 68 + .../Handlers/AdaptiveCardHandler.Response.cs | 136 + .../Handlers/AdaptiveCardHandler.cs | 49 + .../ConversationUpdateHandler.Activity.cs | 164 + .../Handlers/ConversationUpdateHandler.cs | 483 + .../Handlers/EventHandler.Activity.cs | 126 + .../Handlers/EventHandler.cs | 69 + .../Handlers/FileConsentHandler.Value.cs | 74 + .../Handlers/FileConsentHandler.cs | 50 + .../Handlers/InstallUpdateHandler.Activity.cs | 65 + .../Handlers/InstallUpdateHandler.cs | 96 + .../Handlers/InvokeHandler.Activity.cs | 244 + .../Handlers/InvokeHandler.Response.cs | 80 + .../Handlers/InvokeHandler.cs | 49 + .../Handlers/MeetingHandler.Values.cs | 107 + .../Handlers/MeetingHandler.cs | 173 + .../Handlers/MessageDeleteHandler.Activity.cs | 41 + .../Handlers/MessageDeleteHandler.cs | 46 + .../MessageExtensionAction.cs | 347 + .../MessageExtensionActionResponse.cs | 95 + .../MessageExtensionHandler.cs | 307 + .../MessageExtension/MessageExtensionQuery.cs | 77 + .../MessageExtensionQueryLink.cs | 27 + .../MessageExtensionResponse.cs | 360 + .../Handlers/MessageHandler.cs | 104 + .../MessageReactionHandler.Activity.cs | 126 + .../Handlers/MessageReactionHandler.cs | 96 + .../MessageSubmitActionHandler.Value.cs | 26 + .../Handlers/MessageSubmitActionHandler.cs | 46 + .../Handlers/MessageUpdateHandler.Activity.cs | 52 + .../Handlers/MessageUpdateHandler.cs | 46 + .../Handlers/TaskHandler.cs | 79 + .../Handlers/TaskModules/TaskModuleRequest.cs | 36 + .../TaskModules/TaskModuleResponse.cs | 260 + .../Microsoft.Teams.Apps.csproj | 18 + .../Microsoft.Teams.Apps/OAuth/OAuthCard.cs | 47 + .../Microsoft.Teams.Apps/OAuth/OAuthFlow.cs | 467 + .../OAuth/OAuthFlowExtensions.cs | 243 + .../OAuth/OAuthOptions.cs | 27 + .../OAuth/SignInFailureValue.cs | 39 + .../OAuth/SignInTokenExchangeValue.cs | 30 + .../OAuth/SignInVerifyStateValue.cs | 18 + .../OAuth/TokenExchangeInvokeResponse.cs | 31 + core/src/Microsoft.Teams.Apps/README.md | 191 + .../src/Microsoft.Teams.Apps/Routing/Route.cs | 110 + .../Microsoft.Teams.Apps/Routing/Router.cs | 142 + .../Entities/ActivityQuotedReplyExtensions.cs | 82 + .../Schema/Entities/CitationEntity.cs | 437 + .../Schema/Entities/ClientInfoEntity.cs | 118 + .../Schema/Entities/Entity.cs | 167 + .../Schema/Entities/MentionEntity.cs | 115 + .../Schema/Entities/OMessageEntity.cs | 31 + .../Schema/Entities/ProductInfoEntity.cs | 30 + .../Schema/Entities/QuotedReplyEntity.cs | 89 + .../Schema/Entities/SensitiveUsageEntity.cs | 74 + .../Schema/Entities/StreamInfoEntity.cs | 68 + .../Schema/MessageActivity.cs | 193 + .../Schema/MessageActivityExtensions.cs | 100 + .../Schema/StreamingActivity.cs | 37 + .../Schema/SuggestedAction.cs | 131 + .../Schema/SuggestedActions.cs | 68 + core/src/Microsoft.Teams.Apps/Schema/Team.cs | 47 + .../Schema/TeamsActivity.cs | 178 + .../Schema/TeamsActivityBuilder.cs | 347 + .../Schema/TeamsActivityJsonContext.cs | 57 + .../Schema/TeamsActivityType.cs | 104 + .../Schema/TeamsAttachment.cs | 177 + .../Schema/TeamsAttachmentBuilder.cs | 120 + .../Schema/TeamsChannel.cs | 35 + .../Schema/TeamsChannelData.cs | 184 + .../Schema/TeamsConversation.cs | 89 + .../Schema/TeamsConversationAccount.cs | 126 + .../TeamsBotApplication.HostingExtensions.cs | 150 + .../TeamsBotApplication.cs | 186 + .../TeamsBotApplicationOptions.cs | 33 + .../TeamsStreamingWriter.cs | 193 + core/src/Microsoft.Teams.Apps/version.json | 12 + .../Microsoft.Teams.Core/BotApplication.cs | 290 + .../BotHandlerException.cs | 55 + .../ConversationClient.Models.cs | 238 + .../ConversationClient.cs | 580 + .../GlobalSuppressions.cs | 10 + .../Hosting/AddBotApplicationExtensions.cs | 225 + .../Hosting/BotApplicationOptions.cs | 23 + .../Hosting/BotAuthenticationHandler.cs | 144 + .../Hosting/BotClientOptions.cs | 20 + .../Microsoft.Teams.Core/Hosting/BotConfig.cs | 219 + .../Hosting/JwtExtensions.cs | 351 + .../Hosting/MsalConfigurationExtensions.cs | 155 + .../Http/BotHttpClient.cs | 258 + .../Http/BotRequestOptions.cs | 40 + .../HttpRequestExtensions.cs | 33 + .../Microsoft.Teams.Core/ITurnMiddleWare.cs | 34 + core/src/Microsoft.Teams.Core/Log.cs | 115 + .../Microsoft.Teams.Core.csproj | 32 + core/src/Microsoft.Teams.Core/README.md | 162 + .../Schema/ActivityType.cs | 21 + .../Schema/AgenticIdentity.cs | 43 + .../Schema/ChannelData.cs | 21 + .../Schema/Conversation.cs | 25 + .../Schema/ConversationAccount.cs | 76 + .../Schema/CoreActivity.cs | 263 + .../Schema/CoreActivityBuilder.cs | 166 + .../Schema/CoreActivityJsonContext.cs | 27 + .../Microsoft.Teams.Core/TurnMiddleware.cs | 90 + .../UserTokenClient.Models.cs | 88 + .../Microsoft.Teams.Core/UserTokenClient.cs | 293 + .../ABSTokenServiceClient.csproj | 26 + core/test/ABSTokenServiceClient/Program.cs | 14 + .../UserTokenCLIService.cs | 78 + .../ABSTokenServiceClient/appsettings.json | 28 + core/test/IntegrationTests.slnx | 12 + core/test/IntegrationTests/ApiClientTests.cs | 458 + .../IntegrationTests/CompatTeamsInfoTests.cs | 390 + .../ConversationClientTests.cs | 165 + .../CreateConversationDiagnosticTests.cs | 333 + .../CreateConversationTests.cs | 369 + .../IntegrationTestFixture.cs | 144 + .../IntegrationTests/IntegrationTests.csproj | 33 + core/test/IntegrationTests/xunit.runner.json | 4 + .../CompatActivityTests.cs | 438 + .../CompatAdapterTests.cs | 88 + .../CompatBotAdapterTests.cs | 332 + .../CompatConversationsTests.cs | 393 + ...oft.Teams.Apps.BotBuilder.UnitTests.csproj | 38 + .../TestData/AdaptiveCardActivity.json | 75 + .../TestData/SuggestedActionsActivity.json | 241 + .../ActivitiesTests.cs | 144 + .../CitationEntityDeepCopyTests.cs | 69 + .../CitationEntityTests.cs | 380 + .../InvokeActivityTest.cs | 44 + .../MessageActivityTests.cs | 171 + .../Microsoft.Teams.Apps.UnitTests.csproj | 32 + .../OAuthFlowTests.cs | 517 + .../QuotedReplyEntityTests.cs | 417 + .../RouterTests.cs | 109 + .../SuggestedActionsTests.cs | 252 + .../TeamsActivityBuilderTests.cs | 853 + .../TeamsActivityTests.cs | 519 + .../TeamsStreamingWriterTests.cs | 175 + .../BotApplicationTests.cs | 217 + .../ConversationClientTests.cs | 528 + .../CoreActivityBuilderTests.cs | 209 + .../AddBotApplicationExtensionsTests.cs | 326 + .../HttpRequestExtensionsTests.cs | 124 + .../Microsoft.Teams.Core.UnitTests.csproj | 31 + .../MiddlewareTests.cs | 216 + .../Schema/ActivityExtensibilityTests.cs | 157 + .../Schema/CoreActivityTests.cs | 334 + .../Schema/EntitiesTest.cs | 124 + core/test/README.md | 30 + core/test/msal-config-api/Program.cs | 57 + .../msal-config-api/msal-config-api.csproj | 16 + core/version.json | 14 + version.json | 2 +- 450 files changed, 54512 insertions(+), 6376 deletions(-) delete mode 100644 .azdo/publish-preview.yaml create mode 100644 .azdo/publish.yaml delete mode 100644 .azdo/publish.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/workflows/core-ci.yaml create mode 100644 CONTRIBUTING.md create mode 100644 Libraries/Microsoft.Teams.Api/Activities/Invokes/Messages/FetchTaskActivity.cs create mode 100644 Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs create mode 100644 Libraries/Microsoft.Teams.Api/Entities/QuotedReplyEntity.cs create mode 100644 Libraries/Microsoft.Teams.Api/FeedbackLoop.cs create mode 100644 Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FetchTaskActivity.cs create mode 100644 Libraries/Microsoft.Teams.Cards/Utilities/OpenDialogData.cs create mode 100644 Libraries/Microsoft.Teams.Cards/Utilities/SubmitData.cs create mode 100644 Samples/Samples.Quoting/Program.cs create mode 100644 Samples/Samples.Quoting/Properties/launchSettings.TEMPLATE.json create mode 100644 Samples/Samples.Quoting/README.md create mode 100644 Samples/Samples.Quoting/Samples.Quoting.csproj create mode 100644 Samples/Samples.Quoting/appsettings.json create mode 100644 Samples/Samples.Threading/Program.cs create mode 100644 Samples/Samples.Threading/README.md create mode 100644 Samples/Samples.Threading/Samples.Threading.csproj create mode 100644 Samples/Samples.Threading/appsettings.json create mode 100644 Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs create mode 100644 Tests/Microsoft.Teams.Api.Tests/ConversationTests.cs create mode 100644 Tests/Microsoft.Teams.Api.Tests/Entities/QuotedReplyEntityTests.cs create mode 100644 Tests/Microsoft.Teams.Api.Tests/Json/Entities/QuotedReplyEntity.json create mode 100644 Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/Messages/FetchTaskActivityTests.cs create mode 100644 Tests/Microsoft.Teams.Apps.Tests/Contexts/ContextQuotedReplyTests.cs create mode 100644 Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs create mode 100644 core/.editorconfig create mode 100644 core/.gitignore create mode 100644 core/README.md create mode 100644 core/bot_icon.png create mode 100644 core/core.slnx create mode 100644 core/docs/Activity-Design.md create mode 100644 core/docs/ApiClient-Design.md create mode 100644 core/docs/Architecture.md create mode 100644 core/docs/CompatTeamsInfo-API-Mapping.md create mode 100644 core/docs/Core-Compat-PackageDependencies.md create mode 100644 core/docs/CreateConversation-API-Behavior.md create mode 100644 core/docs/MigrationGuide.md create mode 100644 core/docs/ReduceBreakingChangesPlan.md create mode 100644 core/docs/design-decouple-cancellation-token.md create mode 100644 core/docs/sso/OAuthFlow-Design.md create mode 100644 core/docs/sso/oauthflowbot-trace-2026-04-22-sequence-diagrams.md create mode 100644 core/docs/sso/oauthflowbot-trace-2026-04-22-summary.md create mode 100644 core/docs/sso/security-audit-oauthflow-2026-04-22.md create mode 100644 core/docs/sso/sso-trace-2026-04-22-sequence-diagrams.md create mode 100644 core/docs/sso/sso-trace-2026-04-22-summary.md create mode 100644 core/samples/AFBot/AFBot.csproj create mode 100644 core/samples/AFBot/DropTypingMiddleware.cs create mode 100644 core/samples/AFBot/Program.cs create mode 100644 core/samples/AFBot/appsettings.json create mode 100644 core/samples/AllFeatures/AllFeatures.csproj create mode 100644 core/samples/AllFeatures/AllFeatures.http create mode 100644 core/samples/AllFeatures/Program.cs create mode 100644 core/samples/AllFeatures/appsettings.json create mode 100644 core/samples/AllInvokesBot/AllInvokesBot.csproj create mode 100644 core/samples/AllInvokesBot/Cards.cs create mode 100644 core/samples/AllInvokesBot/Program.cs create mode 100644 core/samples/AllInvokesBot/README.md create mode 100644 core/samples/AllInvokesBot/appsettings.json create mode 100644 core/samples/AllInvokesBot/manifest.json create mode 100644 core/samples/CompatBot/Cards.cs create mode 100644 core/samples/CompatBot/CompatBot.csproj create mode 100644 core/samples/CompatBot/EchoBot.cs create mode 100644 core/samples/CompatBot/MyCompatMiddleware.cs create mode 100644 core/samples/CompatBot/Program.cs create mode 100644 core/samples/CompatBot/appsettings.json create mode 100644 core/samples/CompatProactive/CompatProactive.csproj create mode 100644 core/samples/CompatProactive/ProactiveWorker.cs create mode 100644 core/samples/CompatProactive/Program.cs create mode 100644 core/samples/CompatProactive/appsettings.json create mode 100644 core/samples/CoreBot/CoreBot.csproj create mode 100644 core/samples/CoreBot/Program.cs create mode 100644 core/samples/CoreBot/appsettings.json create mode 100644 core/samples/CustomHosting/CustomHosting.csproj create mode 100644 core/samples/CustomHosting/MyTeamsBotApp.cs create mode 100644 core/samples/CustomHosting/Program.cs create mode 100644 core/samples/CustomHosting/appsettings.json create mode 100644 core/samples/Directory.Build.props create mode 100644 core/samples/MeetingsBot/MeetingsBot.csproj create mode 100644 core/samples/MeetingsBot/Program.cs create mode 100644 core/samples/MeetingsBot/README.md create mode 100644 core/samples/MeetingsBot/appsettings.json create mode 100644 core/samples/MessageExtensionBot/Cards.cs create mode 100644 core/samples/MessageExtensionBot/MessageExtensionBot.csproj create mode 100644 core/samples/MessageExtensionBot/Program.cs create mode 100644 core/samples/MessageExtensionBot/README.md create mode 100644 core/samples/MessageExtensionBot/appsettings.json create mode 100644 core/samples/MessageExtensionBot/manifest.json create mode 100644 core/samples/OAuthFlowBot/OAuthFlowBot.csproj create mode 100644 core/samples/OAuthFlowBot/Program.cs create mode 100644 core/samples/OAuthFlowBot/appsettings.json create mode 100644 core/samples/PABot/AdapterWithErrorHandler.cs create mode 100644 core/samples/PABot/Bots/DialogBot.cs create mode 100644 core/samples/PABot/Bots/EchoBot.cs create mode 100644 core/samples/PABot/Bots/SsoBot.cs create mode 100644 core/samples/PABot/Bots/TeamsBot.cs create mode 100644 core/samples/PABot/Dialogs/LogoutDialog.cs create mode 100644 core/samples/PABot/Dialogs/MainDialog.cs create mode 100644 core/samples/PABot/InitCompatAdapter.cs create mode 100644 core/samples/PABot/PABot.csproj create mode 100644 core/samples/PABot/PACustomAuthHandler.cs create mode 100644 core/samples/PABot/Program.cs create mode 100644 core/samples/PABot/Properties/launchSettings.TEMPLATE.json create mode 100644 core/samples/PABot/RoutedTokenAcquisitionService.cs create mode 100644 core/samples/PABot/SimpleGraphClient.cs create mode 100644 core/samples/PABot/appsettings.json create mode 100644 core/samples/Proactive/Proactive.csproj create mode 100644 core/samples/Proactive/Program.cs create mode 100644 core/samples/Proactive/Worker.cs create mode 100644 core/samples/Proactive/appsettings.json create mode 100644 core/samples/Quoting/Program.cs create mode 100644 core/samples/Quoting/Quoting.csproj create mode 100644 core/samples/Quoting/README.md create mode 100644 core/samples/Quoting/appsettings.json create mode 100644 core/samples/SsoBot/Program.cs create mode 100644 core/samples/SsoBot/SsoBot.csproj create mode 100644 core/samples/SsoBot/appsettings.json create mode 100644 core/samples/StreamingBot/Program.cs create mode 100644 core/samples/StreamingBot/StreamingBot.csproj create mode 100644 core/samples/StreamingBot/appsettings.json create mode 100644 core/samples/TabApp/Body.cs create mode 100644 core/samples/TabApp/Program.cs create mode 100644 core/samples/TabApp/README.md create mode 100644 core/samples/TabApp/TabApp.csproj create mode 100644 core/samples/TabApp/Web/index.html create mode 100644 core/samples/TabApp/Web/package-lock.json create mode 100644 core/samples/TabApp/Web/package.json create mode 100644 core/samples/TabApp/Web/src/App.css create mode 100644 core/samples/TabApp/Web/src/App.tsx create mode 100644 core/samples/TabApp/Web/src/main.tsx create mode 100644 core/samples/TabApp/Web/tsconfig.json create mode 100644 core/samples/TabApp/Web/vite.config.ts create mode 100644 core/samples/TabApp/appsettings.json create mode 100644 core/samples/TeamsBot/Cards.cs create mode 100644 core/samples/TeamsBot/GlobalSuppressions.cs create mode 100644 core/samples/TeamsBot/Program.cs create mode 100644 core/samples/TeamsBot/Properties/launchSettings.TEMPLATE.json create mode 100644 core/samples/TeamsBot/TeamsBot.csproj create mode 100644 core/samples/TeamsBot/WelcomeMessageMiddleware.cs create mode 100644 core/samples/TeamsBot/appsettings.json create mode 100644 core/samples/TeamsChannelBot/Program.cs create mode 100644 core/samples/TeamsChannelBot/README.md create mode 100644 core/samples/TeamsChannelBot/TeamsChannelBot.csproj create mode 100644 core/samples/TeamsChannelBot/appsettings.json create mode 100644 core/src/Directory.Build.props create mode 100644 core/src/Directory.Build.targets create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/ActivitySchemaMapper.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/CompatConnectorClient.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/CompatConversations.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/CompatHostingExtensions.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/CompatTeamsInfo.Models.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/CompatUserTokenClient.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/InternalsVisibleTo.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/Log.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/Microsoft.Teams.Apps.BotBuilder.csproj create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/README.md create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/TeamsApiClient.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/TeamsBotAdapter.cs create mode 100644 core/src/Microsoft.Teams.Apps.BotBuilder/TeamsBotFrameworkHttpAdapter.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/ActivityClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/ApiClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/BotClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/BotSignInClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/BotTokenClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/MeetingClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/MemberClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/ReactionClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/TeamClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/UserClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/Api/Clients/UserTokenApiClient.cs create mode 100644 core/src/Microsoft.Teams.Apps/AppBuilder.cs create mode 100644 core/src/Microsoft.Teams.Apps/Context.cs create mode 100644 core/src/Microsoft.Teams.Apps/ContextLogger.cs create mode 100644 core/src/Microsoft.Teams.Apps/GlobalSuppressions.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.ActionValue.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.Response.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/ConversationUpdateHandler.Activity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/ConversationUpdateHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/EventHandler.Activity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/EventHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/FileConsentHandler.Value.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/FileConsentHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/InstallUpdateHandler.Activity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/InstallUpdateHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Response.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MeetingHandler.Values.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MeetingHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageDeleteHandler.Activity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageDeleteHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionAction.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionActionResponse.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionQuery.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionQueryLink.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionResponse.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageReactionHandler.Activity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageReactionHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.Value.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageUpdateHandler.Activity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/MessageUpdateHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/TaskHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/TaskModules/TaskModuleRequest.cs create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/TaskModules/TaskModuleResponse.cs create mode 100644 core/src/Microsoft.Teams.Apps/Microsoft.Teams.Apps.csproj create mode 100644 core/src/Microsoft.Teams.Apps/OAuth/OAuthCard.cs create mode 100644 core/src/Microsoft.Teams.Apps/OAuth/OAuthFlow.cs create mode 100644 core/src/Microsoft.Teams.Apps/OAuth/OAuthFlowExtensions.cs create mode 100644 core/src/Microsoft.Teams.Apps/OAuth/OAuthOptions.cs create mode 100644 core/src/Microsoft.Teams.Apps/OAuth/SignInFailureValue.cs create mode 100644 core/src/Microsoft.Teams.Apps/OAuth/SignInTokenExchangeValue.cs create mode 100644 core/src/Microsoft.Teams.Apps/OAuth/SignInVerifyStateValue.cs create mode 100644 core/src/Microsoft.Teams.Apps/OAuth/TokenExchangeInvokeResponse.cs create mode 100644 core/src/Microsoft.Teams.Apps/README.md create mode 100644 core/src/Microsoft.Teams.Apps/Routing/Route.cs create mode 100644 core/src/Microsoft.Teams.Apps/Routing/Router.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/ActivityQuotedReplyExtensions.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/ClientInfoEntity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/Entity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/MentionEntity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/OMessageEntity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/ProductInfoEntity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/QuotedReplyEntity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/SensitiveUsageEntity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Entities/StreamInfoEntity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/MessageActivity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/MessageActivityExtensions.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/StreamingActivity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/SuggestedAction.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/SuggestedActions.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/Team.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsActivity.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsActivityBuilder.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsActivityJsonContext.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsActivityType.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsAttachment.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsAttachmentBuilder.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsChannel.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsConversation.cs create mode 100644 core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs create mode 100644 core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs create mode 100644 core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs create mode 100644 core/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs create mode 100644 core/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs create mode 100644 core/src/Microsoft.Teams.Apps/version.json create mode 100644 core/src/Microsoft.Teams.Core/BotApplication.cs create mode 100644 core/src/Microsoft.Teams.Core/BotHandlerException.cs create mode 100644 core/src/Microsoft.Teams.Core/ConversationClient.Models.cs create mode 100644 core/src/Microsoft.Teams.Core/ConversationClient.cs create mode 100644 core/src/Microsoft.Teams.Core/GlobalSuppressions.cs create mode 100644 core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs create mode 100644 core/src/Microsoft.Teams.Core/Hosting/BotApplicationOptions.cs create mode 100644 core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs create mode 100644 core/src/Microsoft.Teams.Core/Hosting/BotClientOptions.cs create mode 100644 core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs create mode 100644 core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs create mode 100644 core/src/Microsoft.Teams.Core/Hosting/MsalConfigurationExtensions.cs create mode 100644 core/src/Microsoft.Teams.Core/Http/BotHttpClient.cs create mode 100644 core/src/Microsoft.Teams.Core/Http/BotRequestOptions.cs create mode 100644 core/src/Microsoft.Teams.Core/HttpRequestExtensions.cs create mode 100644 core/src/Microsoft.Teams.Core/ITurnMiddleWare.cs create mode 100644 core/src/Microsoft.Teams.Core/Log.cs create mode 100644 core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj create mode 100644 core/src/Microsoft.Teams.Core/README.md create mode 100644 core/src/Microsoft.Teams.Core/Schema/ActivityType.cs create mode 100644 core/src/Microsoft.Teams.Core/Schema/AgenticIdentity.cs create mode 100644 core/src/Microsoft.Teams.Core/Schema/ChannelData.cs create mode 100644 core/src/Microsoft.Teams.Core/Schema/Conversation.cs create mode 100644 core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs create mode 100644 core/src/Microsoft.Teams.Core/Schema/CoreActivity.cs create mode 100644 core/src/Microsoft.Teams.Core/Schema/CoreActivityBuilder.cs create mode 100644 core/src/Microsoft.Teams.Core/Schema/CoreActivityJsonContext.cs create mode 100644 core/src/Microsoft.Teams.Core/TurnMiddleware.cs create mode 100644 core/src/Microsoft.Teams.Core/UserTokenClient.Models.cs create mode 100644 core/src/Microsoft.Teams.Core/UserTokenClient.cs create mode 100644 core/test/ABSTokenServiceClient/ABSTokenServiceClient.csproj create mode 100644 core/test/ABSTokenServiceClient/Program.cs create mode 100644 core/test/ABSTokenServiceClient/UserTokenCLIService.cs create mode 100644 core/test/ABSTokenServiceClient/appsettings.json create mode 100644 core/test/IntegrationTests.slnx create mode 100644 core/test/IntegrationTests/ApiClientTests.cs create mode 100644 core/test/IntegrationTests/CompatTeamsInfoTests.cs create mode 100644 core/test/IntegrationTests/ConversationClientTests.cs create mode 100644 core/test/IntegrationTests/CreateConversationDiagnosticTests.cs create mode 100644 core/test/IntegrationTests/CreateConversationTests.cs create mode 100644 core/test/IntegrationTests/IntegrationTestFixture.cs create mode 100644 core/test/IntegrationTests/IntegrationTests.csproj create mode 100644 core/test/IntegrationTests/xunit.runner.json create mode 100644 core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatActivityTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatAdapterTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatBotAdapterTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatConversationsTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/Microsoft.Teams.Apps.BotBuilder.UnitTests.csproj create mode 100644 core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/TestData/AdaptiveCardActivity.json create mode 100644 core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/TestData/SuggestedActionsActivity.json create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/ActivitiesTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/CitationEntityDeepCopyTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/CitationEntityTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/InvokeActivityTest.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/MessageActivityTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/OAuthFlowTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/QuotedReplyEntityTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/RouterTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/SuggestedActionsTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/ConversationClientTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/CoreActivityBuilderTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/HttpRequestExtensionsTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Microsoft.Teams.Core.UnitTests.csproj create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/MiddlewareTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Schema/ActivityExtensibilityTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Schema/CoreActivityTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Schema/EntitiesTest.cs create mode 100644 core/test/README.md create mode 100644 core/test/msal-config-api/Program.cs create mode 100644 core/test/msal-config-api/msal-config-api.csproj create mode 100644 core/version.json diff --git a/.azdo/cd-core.yaml b/.azdo/cd-core.yaml index bea664529..6949eb523 100644 --- a/.azdo/cd-core.yaml +++ b/.azdo/cd-core.yaml @@ -1,13 +1,16 @@ # ============================================================================= # This pipeline (BotCore-CD) builds, tests, and packs the core/ project. -# PR trigger: next/* branches (all paths). CI trigger: next/* branches (core/** paths only). -# Pushes packages to internal preview feed on next/core branch. +# PR trigger: core/* and main branches (all paths). +# CI trigger: core/* and main branches (core/** paths only). +# Pushes packages to the TeamsSDKPreviews internal feed when the +# PushToADOFeed pipeline variable is set to true. # ============================================================================= pr: branches: include: - - next/* + - core/* + - main # Uncomment and edit the following lines to add path filters for PRs in the future # paths: # include: @@ -16,7 +19,8 @@ pr: trigger: branches: include: - - next/* + - core/* + - main paths: include: - core/** @@ -43,7 +47,7 @@ stages: packageType: 'sdk' version: '10.0.x' - - script: dotnet restore + - script: dotnet restore displayName: 'Restore' workingDirectory: '$(Build.SourcesDirectory)/core' @@ -61,7 +65,7 @@ stages: - task: NuGetCommand@2 displayName: 'Push NuGet Packages' - condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/next/core')) + condition: and(succeeded(), eq(variables['PushToADOFeed'], 'true')) inputs: command: push packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg' @@ -73,4 +77,4 @@ stages: inputs: targetPath: '$(Build.ArtifactStagingDirectory)' artifact: 'Packages' - publishLocation: 'pipeline' \ No newline at end of file + publishLocation: 'pipeline' diff --git a/.azdo/ci.yaml b/.azdo/ci.yaml index 92c6dbc5a..979b2326b 100644 --- a/.azdo/ci.yaml +++ b/.azdo/ci.yaml @@ -10,10 +10,7 @@ pr: include: - main - release/* - paths: - exclude: - - core/** - + pool: vmImage: 'ubuntu-22.04' diff --git a/.azdo/publish-preview.yaml b/.azdo/publish-preview.yaml deleted file mode 100644 index f101593ab..000000000 --- a/.azdo/publish-preview.yaml +++ /dev/null @@ -1,158 +0,0 @@ -# ============================================================================= -# This pipeline publishes preview packages. Manually triggered only. -# - "Internal": pushes unsigned packages to the internal TeamsSDKPreviews feed. -# - "Public": signs (Authenticode + NuGet) and pushes to nuget.org (requires approval). -# ============================================================================= - -trigger: none - -pr: none - -parameters: -- name: publishType - displayName: 'Publish Type' - type: string - default: 'Internal' - values: - - Internal - - Public - -pool: - vmImage: 'ubuntu-22.04' - -variables: - buildConfiguration: 'Release' - folderPath: '$(Build.SourcesDirectory)' - ${{ if eq(parameters.publishType, 'Public') }}: - appRegistrationTenantId: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' - authenticodeSignId: '2d5c4ab9-0b7e-4f60-bb92-70322df77b94' - nugetSignId: 'a94a770a-9a7b-4888-a3ea-24584b851e49' - -stages: - -- ${{ if eq(parameters.publishType, 'Internal') }}: - - stage: Build_Test_Pack_Push_Internal - displayName: 'Build, Test, Pack, and Push (Internal Preview)' - jobs: - - job: BuildTestPackPush - displayName: 'Build, Test, Pack, and Push to Internal Feed' - steps: - - checkout: self - - - task: UseDotNet@2 - displayName: 'Use .NET 8' - inputs: - packageType: 'sdk' - version: '8.0.x' - - - task: UseDotNet@2 - displayName: 'Use .NET 10' - inputs: - packageType: 'sdk' - version: '10.0.x' - - - script: dotnet restore - displayName: 'Restore' - - - script: dotnet build --no-restore --configuration $(buildConfiguration) - displayName: 'Build' - - - script: dotnet test --no-build --verbosity normal --logger trx --configuration $(buildConfiguration) - displayName: 'Test' - - - task: PublishTestResults@2 - displayName: 'Publish Test Results' - condition: succeededOrFailed() - inputs: - testResultsFormat: 'VSTest' - testResultsFiles: '**/*.trx' - mergeTestResults: true - - - script: dotnet pack --no-build -o $(Build.ArtifactStagingDirectory) /p:SymbolPackageFormat=snupkg --configuration $(buildConfiguration) - displayName: 'Pack' - - - task: NuGetCommand@2 - displayName: 'Push NuGet Packages to Internal Feed' - inputs: - command: push - packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg' - nuGetFeedType: internal - publishVstsFeed: '$(System.TeamProject)/TeamsSDKPreviews' - - - task: PublishPipelineArtifact@1 - displayName: 'Publish NuGet Packages as Pipeline Artifact' - inputs: - targetPath: '$(Build.ArtifactStagingDirectory)' - artifact: 'PreviewPackages' - publishLocation: 'pipeline' - -- ${{ if eq(parameters.publishType, 'Public') }}: - - stage: Build_Test_Sign_Pack - displayName: 'Build, Test, Sign, and Pack (Public Preview)' - jobs: - - job: BuildTestSignPack - displayName: 'Build, Test, Sign, and Pack' - steps: - - checkout: self - - - task: UseDotNet@2 - displayName: 'Use .NET 8' - inputs: - packageType: 'sdk' - version: '8.0.x' - - - task: UseDotNet@2 - displayName: 'Use .NET 10' - inputs: - packageType: 'sdk' - version: '10.0.x' - - - script: dotnet restore - displayName: 'Restore' - - - script: dotnet build --no-restore --configuration $(buildConfiguration) - displayName: 'Build' - - - script: dotnet test --no-build --verbosity normal --logger trx --configuration $(buildConfiguration) - displayName: 'Test' - - - task: PublishTestResults@2 - displayName: 'Publish Test Results' - condition: succeededOrFailed() - inputs: - testResultsFormat: 'VSTest' - testResultsFiles: '**/*.trx' - mergeTestResults: true - - - template: templates/sign-and-pack.yaml - - - task: PublishPipelineArtifact@1 - displayName: 'Publish NuGet Packages as Pipeline Artifact' - inputs: - targetPath: '$(Build.ArtifactStagingDirectory)' - artifact: 'PreviewPackages' - publishLocation: 'pipeline' - -- ${{ if eq(parameters.publishType, 'Public') }}: - - stage: PushToNuGet - displayName: 'Push Preview Packages to nuget.org' - dependsOn: Build_Test_Sign_Pack - jobs: - - deployment: PushPackages - displayName: 'Manual Approval Required to Push Packages' - environment: - name: 'teams-net-publish' - strategy: - runOnce: - deploy: - steps: - - download: current - artifact: PreviewPackages - - - task: NuGetCommand@2 - displayName: 'Push NuGet Packages' - inputs: - command: push - packagesToPush: '$(Pipeline.Workspace)/PreviewPackages/*.nupkg' - nuGetFeedType: external - publishFeedCredentials: 'Microsoft.Teams.*' diff --git a/.azdo/publish.yaml b/.azdo/publish.yaml new file mode 100644 index 000000000..4706f331c --- /dev/null +++ b/.azdo/publish.yaml @@ -0,0 +1,227 @@ +# ============================================================================= +# This pipeline publishes NuGet packages. Manually triggered only. +# - "Internal": pushes unsigned packages to the internal TeamsSDKPreviews feed. +# - "Public": signs (Authenticode + NuGet) and pushes to nuget.org (requires approval). +# Version is determined by Nerdbank.GitVersioning (nbgv) from version.json. +# +# MIGRATED TO 1ES OFFICIAL PIPELINE TEMPLATE: This pipeline now extends from +# 1ES.Official.PipelineTemplate to ensure compliance with M365 security requirements. +# ============================================================================= + +resources: + repositories: + - repository: 1esPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +trigger: none + +pr: none + +parameters: +- name: publishType + displayName: 'Publish Type' + type: string + default: 'Internal' + values: + - Internal + - Public + +variables: + buildConfiguration: 'Release' + folderPath: '$(Build.SourcesDirectory)' + ${{ if eq(parameters.publishType, 'Public') }}: + appRegistrationTenantId: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' + authenticodeSignId: '2d5c4ab9-0b7e-4f60-bb92-70322df77b94' + nugetSignId: 'a94a770a-9a7b-4888-a3ea-24584b851e49' + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + parameters: + pool: + name: Azure-Pipelines-1ESPT-ExDShared + image: ubuntu-22.04 + os: linux + + sdl: + sourceAnalysisPool: + name: Azure-Pipelines-1ESPT-ExDShared + image: windows-2022 + os: windows + + # Required for M365PT tracking and drift management + customBuildTags: + - ES365AIMigrationTooling + + stages: + + - ${{ if eq(parameters.publishType, 'Internal') }}: + - stage: Build_Test_Pack_Push_Internal + displayName: 'Build, Test, Pack, and Push (Internal)' + jobs: + - job: BuildTestPackPush + displayName: 'Build, Test, Pack, and Push to Internal Feed' + steps: + - checkout: self + - task: UseDotNet@2 + displayName: 'Use .NET 8' + inputs: + packageType: 'sdk' + version: '8.0.x' + + - task: UseDotNet@2 + displayName: 'Use .NET 10' + inputs: + packageType: 'sdk' + version: '10.0.x' + + - pwsh: | + $nugetConfig = @" + + + + + + + + "@ + $nugetConfig | Out-File -FilePath "$(Build.SourcesDirectory)/nuget.config" -Encoding utf8 + displayName: 'Create nuget.config' + + - task: NuGetAuthenticate@1 + displayName: 'Authenticate with NuGet feeds' + + - script: dotnet restore + displayName: 'Restore' + + - script: dotnet build --no-restore --configuration $(buildConfiguration) + displayName: 'Build' + + - script: dotnet test --no-build --verbosity normal --logger trx --configuration $(buildConfiguration) + displayName: 'Test' + + - task: PublishTestResults@2 + displayName: 'Publish Test Results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'VSTest' + testResultsFiles: '**/*.trx' + mergeTestResults: true + + - task: DotNetCoreCLI@2 + displayName: 'Pack' + inputs: + command: 'pack' + packagesToPack: 'Libraries/**/*.csproj' + nobuild: true + configuration: '$(buildConfiguration)' + outputDir: '$(Build.ArtifactStagingDirectory)' + buildProperties: 'SymbolPackageFormat=snupkg' + + - task: 1ES.PublishNuget@1 + displayName: 'Push NuGet Packages to Internal Feed' + inputs: + useDotNetTask: false + packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg' + packageParentPath: '$(Build.ArtifactStagingDirectory)' + publishVstsFeed: '$(System.TeamProject)/TeamsSDKPreviews' + nuGetFeedType: internal + allowPackageConflicts: false # Allow duplicate versions for preview testing + publishPackageMetadata: true + retryCountOnTaskFailure: 2 + + - task: 1ES.PublishPipelineArtifact@1 + displayName: 'Publish NuGet Packages as Pipeline Artifact' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)' + artifactName: 'Packages' + + - ${{ if eq(parameters.publishType, 'Public') }}: + - stage: Build_Test_Sign_Pack + displayName: 'Build, Test, Sign, and Pack (Public)' + jobs: + - job: BuildTestSignPack + displayName: 'Build, Test, Sign, and Pack' + steps: + - checkout: self + + - task: UseDotNet@2 + displayName: 'Use .NET 8' + inputs: + packageType: 'sdk' + version: '8.0.x' + + - task: UseDotNet@2 + displayName: 'Use .NET 10' + inputs: + packageType: 'sdk' + version: '10.0.x' + + - pwsh: | + $nugetConfig = @" + + + + + + + + "@ + $nugetConfig | Out-File -FilePath "$(Build.SourcesDirectory)/nuget.config" -Encoding utf8 + displayName: 'Create nuget.config' + + - task: NuGetAuthenticate@1 + displayName: 'Authenticate with NuGet feeds' + + - script: dotnet restore + displayName: 'Restore' + + - script: dotnet build --no-restore --configuration $(buildConfiguration) + displayName: 'Build' + + - script: dotnet test --no-build --verbosity normal --logger trx --configuration $(buildConfiguration) + displayName: 'Test' + + - task: PublishTestResults@2 + displayName: 'Publish Test Results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'VSTest' + testResultsFiles: '**/*.trx' + mergeTestResults: true + + - template: .azdo/templates/sign-and-pack.yaml@self + + - task: 1ES.PublishPipelineArtifact@1 + displayName: 'Publish NuGet Packages as Pipeline Artifact' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)' + artifactName: 'Packages' + + - ${{ if eq(parameters.publishType, 'Public') }}: + - stage: PushToNuGet + displayName: 'Push Packages to nuget.org' + dependsOn: Build_Test_Sign_Pack + jobs: + - deployment: PushPackages + displayName: 'Manual Approval Required to Push Packages' + environment: + name: 'teams-net-publish' + strategy: + runOnce: + deploy: + steps: + - download: current + artifact: Packages + + - task: 1ES.PublishNuget@1 + displayName: 'Push Packages to nuget.org' + inputs: + useDotNetTask: false + packagesToPush: '$(Pipeline.Workspace)/Packages/*.nupkg' + packageParentPath: '$(Pipeline.Workspace)/Packages' + nuGetFeedType: external + publishFeedCredentials: 'Microsoft.Teams.*' + publishPackageMetadata: true + retryCountOnTaskFailure: 2 diff --git a/.azdo/publish.yml b/.azdo/publish.yml deleted file mode 100644 index f33373a94..000000000 --- a/.azdo/publish.yml +++ /dev/null @@ -1,87 +0,0 @@ -# ============================================================================= -# For public releases, this pipeline (teams.net) is triggered manually. -# It builds, tests, signs (Authenticode + NuGet), and packs the code, then pushes to nuget.org (requires approval). -# ============================================================================= - -trigger: none - -pr: none - -pool: - vmImage: 'ubuntu-22.04' - -variables: - buildConfiguration: 'Release' - folderPath: '$(Build.SourcesDirectory)' - appRegistrationTenantId: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' - authenticodeSignId: '2d5c4ab9-0b7e-4f60-bb92-70322df77b94' - nugetSignId: 'a94a770a-9a7b-4888-a3ea-24584b851e49' - -stages: -- stage: Build_Test_Sign_Pack - jobs: - - job: BuildTestSignPack - displayName: 'Build, Test, Sign, and Pack' - steps: - - checkout: self - - - task: UseDotNet@2 - displayName: 'Use .NET 8' - inputs: - packageType: 'sdk' - version: '8.0.x' - - - task: UseDotNet@2 - displayName: 'Use .NET 10' - inputs: - packageType: 'sdk' - version: '10.0.x' - - - script: dotnet restore - displayName: 'Restore' - - - script: dotnet build --no-restore --configuration $(buildConfiguration) - displayName: 'Build' - - - script: dotnet test --no-build --verbosity normal --logger trx --configuration $(buildConfiguration) - displayName: 'Test' - - - task: PublishTestResults@2 - displayName: 'Publish Test Results' - condition: succeededOrFailed() - inputs: - testResultsFormat: 'VSTest' - testResultsFiles: '**/*.trx' - mergeTestResults: true - - - template: templates/sign-and-pack.yaml - - - task: PublishPipelineArtifact@1 - displayName: 'Publish NuGet Packages as Pipeline Artifact' - inputs: - targetPath: '$(Build.ArtifactStagingDirectory)' - artifact: 'Packages' - publishLocation: 'pipeline' - -- stage: PushToNuGet - displayName: 'Push NuGet Packages to nuget.org' - dependsOn: Build_Test_Sign_Pack - jobs: - - deployment: PushPackages - displayName: 'Manual Approval Required to Push Packages' - environment: - name: 'teams-net-publish' - strategy: - runOnce: - deploy: - steps: - - download: current - artifact: Packages - - - task: NuGetCommand@2 - displayName: 'Push NuGet Packages' - inputs: - command: push - packagesToPush: '$(Pipeline.Workspace)/Packages/*.nupkg' - nuGetFeedType: external - publishFeedCredentials: 'Microsoft.Teams.*' \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4e99def31..9cc0b8757 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/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..dbade9abf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,72 @@ +name: Bug Report +description: Report a bug or issue with the Teams .NET SDK +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the information below. + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is + placeholder: Tell us what you see! + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Install package '...' + 2. Run code '...' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened + validations: + required: true + + - type: input + id: version + attributes: + label: SDK Version + description: Which version of the Teams .NET SDK are you using? + placeholder: e.g., 1.0.0 + validations: + required: true + + - type: input + id: dotnet-version + attributes: + label: .NET Version + description: Which version of .NET are you using? + placeholder: e.g., 8.0 + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context about the problem here (logs, screenshots, etc.) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..a052bfcf5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question or Feature Request + url: https://github.com/microsoft/teams-sdk/discussions + about: Please use GitHub Discussions for questions and feature requests diff --git a/.github/workflows/build-test-lint.yml b/.github/workflows/build-test-lint.yml index 6984057fa..ecdd325bc 100644 --- a/.github/workflows/build-test-lint.yml +++ b/.github/workflows/build-test-lint.yml @@ -9,14 +9,13 @@ on: - '**/*.md' - 'docs/**' - 'Assets/**' - - 'core/**' push: branches: ['main'] paths-ignore: - '**/*.md' - 'docs/**' - 'Assets/**' - - 'core/**' + permissions: read-all jobs: diff --git a/.github/workflows/core-ci.yaml b/.github/workflows/core-ci.yaml new file mode 100644 index 000000000..e1b5f79f8 --- /dev/null +++ b/.github/workflows/core-ci.yaml @@ -0,0 +1,38 @@ +name: Core-CI +permissions: + contents: read + pull-requests: write + +on: + pull_request: + branches: [ main, 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/.gitignore b/.gitignore index def52263e..1d4877a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ +.lscache # Visual Studio 2015/2017 cache/options directory .vs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..219d88612 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +Please refer to this sub-module's root repo Contributing guide at [Teams SDK Contributing](https://github.com/microsoft/teams-sdk/blob/main/CONTRIBUTING.md) + +## Multi-Language SDK + +The Teams SDK is maintained across three languages: **Python**, **TypeScript**, and **.NET**. When proposing new features, please discuss them in a language-agnostic way in [GitHub Discussions](https://github.com/microsoft/teams-sdk/discussions). This ensures that features can be implemented consistently across all three SDKs and benefits the entire Teams developer community. diff --git a/Libraries/Microsoft.Teams.Api/Activities/Activity.cs b/Libraries/Microsoft.Teams.Api/Activities/Activity.cs index 6c7dc4a61..0401e3869 100644 --- a/Libraries/Microsoft.Teams.Api/Activities/Activity.cs +++ b/Libraries/Microsoft.Teams.Api/Activities/Activity.cs @@ -82,8 +82,10 @@ public partial interface IActivity : IConvertible, ICloneable public string GetPath(); /// - /// get the quote reply string form of this activity + /// Generates a quoted reply placeholder for the current activity. + /// See for the recommended approach. /// + [Obsolete("Use MessageActivity.AddQuote() instead.")] public string ToQuoteReply(); } @@ -192,12 +194,6 @@ public virtual Activity WithId(string value) return this; } - public virtual Activity WithReplyToId(string value) - { - ReplyToId = value; - return this; - } - public virtual Activity WithChannelId(ChannelId value) { ChannelId = value; @@ -225,9 +221,9 @@ public virtual Activity WithRelatesTo(ConversationReference value) public virtual Activity WithRecipient(Account value) { Recipient = value; - #pragma warning disable ExperimentalTeamsTargeted +#pragma warning disable ExperimentalTeamsTargeted Recipient.IsTargeted = null; - #pragma warning restore ExperimentalTeamsTargeted +#pragma warning restore ExperimentalTeamsTargeted return this; } @@ -235,9 +231,9 @@ public virtual Activity WithRecipient(Account value) public virtual Activity WithRecipient(Account value, bool isTargeted) { Recipient = value; - #pragma warning disable ExperimentalTeamsTargeted +#pragma warning disable ExperimentalTeamsTargeted Recipient.IsTargeted = isTargeted ? true : null; - #pragma warning restore ExperimentalTeamsTargeted +#pragma warning restore ExperimentalTeamsTargeted return this; } @@ -269,9 +265,31 @@ public virtual Activity WithData(ChannelData value) { ChannelData ??= new(); ChannelData.Merge(value); + NormalizeFeedback(); return this; } + /// + /// The Teams service rejects feedbackLoop and feedbackLoopEnabled + /// set at the same time. When is set it + /// wins; otherwise a legacy FeedbackLoopEnabled = true is upgraded to + /// . + /// + private void NormalizeFeedback() + { + if (ChannelData is null) return; + + if (ChannelData.FeedbackLoop is not null) + { + ChannelData.FeedbackLoopEnabled = null; + } + else if (ChannelData.FeedbackLoopEnabled == true) + { + ChannelData.FeedbackLoop = new FeedbackLoop(FeedbackType.Default); + ChannelData.FeedbackLoopEnabled = null; + } + } + public virtual Activity WithData(string key, object? value) { ChannelData ??= new(); @@ -373,12 +391,39 @@ public virtual Activity AddSensitivityLabel(string name, string? description = n } /// - /// enable/disable message feedback + /// Legacy builder method of enabling default message feedback. /// + /// Whether to enable default message feedback. public virtual Activity AddFeedback(bool value = true) { ChannelData ??= new(); - ChannelData.FeedbackLoopEnabled = value; + + if (value) + { + ChannelData.FeedbackLoop = new FeedbackLoop(FeedbackType.Default); + } + else + { + ChannelData.FeedbackLoop = null; + } + + ChannelData.FeedbackLoopEnabled = null; + return this; + } + + /// + /// Enable message feedback with an explicit mode (default or custom). + /// + /// + /// shows Teams' built-in thumbs up/down UI. + /// triggers a message/fetchTask invoke + /// so the bot can return its own task module dialog. + /// + public virtual Activity AddFeedback(FeedbackType mode) + { + ChannelData ??= new(); + ChannelData.FeedbackLoop = new FeedbackLoop(mode); + ChannelData.FeedbackLoopEnabled = null; return this; } @@ -446,24 +491,15 @@ public Activity Merge(Activity from) return this; } - public string ToQuoteReply() + /// + /// Generates a quoted reply placeholder for the current activity. + /// See for the recommended approach. + /// + [Obsolete("Use MessageActivity.AddQuote() instead.")] + public virtual string ToQuoteReply() { - var text = string.Empty; - - if (this is MessageActivity message) - { - text = $"

{message.Text}

"; - } - - return $""" -
- - {From.Name} - - - {text} -
- """; + if (Id == null) return string.Empty; + return $""; } public override string ToString() diff --git a/Libraries/Microsoft.Teams.Api/Activities/Invokes/MessageActivity.cs b/Libraries/Microsoft.Teams.Api/Activities/Invokes/MessageActivity.cs index 4135347f2..05e4dab59 100644 --- a/Libraries/Microsoft.Teams.Api/Activities/Invokes/MessageActivity.cs +++ b/Libraries/Microsoft.Teams.Api/Activities/Invokes/MessageActivity.cs @@ -20,10 +20,12 @@ public partial class Name : StringEnum public abstract class MessageActivity(Name.Messages name) : InvokeActivity(new(name.Value)) { public Messages.SubmitActionActivity ToSubmitAction() => (Messages.SubmitActionActivity)this; + public Messages.FetchTaskActivity ToFetchTask() => (Messages.FetchTaskActivity)this; public override object ToType(Type type, IFormatProvider? provider) { if (type == typeof(Messages.SubmitActionActivity)) return ToSubmitAction(); + if (type == typeof(Messages.FetchTaskActivity)) return ToFetchTask(); return this; } @@ -53,6 +55,7 @@ public override bool CanConvert(Type typeToConvert) return name switch { "message/submitAction" => JsonSerializer.Deserialize(element.ToString(), options), + "message/fetchTask" => JsonSerializer.Deserialize(element.ToString(), options), _ => throw new JsonException($"failed to deserialize message activity '{name}' doesn't match any known types.") }; } @@ -65,6 +68,12 @@ public override void Write(Utf8JsonWriter writer, MessageActivity value, JsonSer return; } + if (value is Messages.FetchTaskActivity fetchTask) + { + JsonSerializer.Serialize(writer, fetchTask, options); + return; + } + JsonSerializer.Serialize(writer, value, options); } } diff --git a/Libraries/Microsoft.Teams.Api/Activities/Invokes/Messages/FetchTaskActivity.cs b/Libraries/Microsoft.Teams.Api/Activities/Invokes/Messages/FetchTaskActivity.cs new file mode 100644 index 000000000..267c3d8d0 --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Activities/Invokes/Messages/FetchTaskActivity.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +using Microsoft.Teams.Common; + +namespace Microsoft.Teams.Api.Activities.Invokes; + +public partial class Name : StringEnum +{ + public partial class Messages : StringEnum + { + public static readonly Messages FetchTask = new("message/fetchTask"); + public bool IsFetchTask => FetchTask.Equals(Value); + } +} + +/// +/// The feedback button the user clicked. +/// +[JsonConverter(typeof(JsonConverter))] +public partial class Reaction(string value) : StringEnum(value) +{ + public static readonly Reaction Like = new("like"); + public bool IsLike => Like.Equals(Value); + + public static readonly Reaction Dislike = new("dislike"); + public bool IsDislike => Dislike.Equals(Value); +} + +public static partial class Messages +{ + /// + /// Sent when a message has a custom feedback loop and the user clicks a + /// feedback button. The bot should respond with a task module (dialog) to + /// collect feedback. + /// + public class FetchTaskActivity() : MessageActivity(Name.Messages.FetchTask) + { + /// + /// A value that is associated with the activity. + /// + [JsonPropertyName("value")] + [JsonPropertyOrder(32)] + public new required FetchTaskValue Value + { + get => (FetchTaskValue)base.Value!; + set => base.Value = value; + } + + /// + /// The value associated with a message fetch task. + /// + public class FetchTaskValue + { + /// + /// The data payload containing action name and value. + /// + [JsonPropertyName("data")] + [JsonPropertyOrder(0)] + public required FetchTaskData Data { get; set; } + } + + /// + /// The data payload nested inside the fetch task value. + /// + public class FetchTaskData + { + /// + /// The name of the action. + /// + [JsonPropertyName("actionName")] + [JsonPropertyOrder(0)] + public string ActionName { get; set; } = "feedback"; + + /// + /// Contains the user's reaction. + /// + [JsonPropertyName("actionValue")] + [JsonPropertyOrder(1)] + public required FetchTaskActionValue ActionValue { get; set; } + } + + /// + /// The nested action value containing the user's reaction. + /// + public class FetchTaskActionValue + { + /// + /// The feedback button the user clicked. + /// + [JsonPropertyName("reaction")] + [JsonPropertyOrder(0)] + public required Reaction Reaction { get; set; } + } + } +} diff --git a/Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs b/Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs index 5b1fa4524..6150d4ac6 100644 --- a/Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs +++ b/Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs @@ -70,6 +70,17 @@ public bool IsRecipientMentioned { get => (Entities ?? []).Any(e => e is MentionEntity mention && mention.Mentioned.Id == Recipient.Id); } + /// + /// Get all quoted reply entities from this message. + /// + [Experimental("ExperimentalTeamsQuotedReplies")] +#pragma warning disable ExperimentalTeamsQuotedReplies + public IReadOnlyList GetQuotedMessages() + { + return Entities?.OfType().ToList() + ?? new List(); + } +#pragma warning restore ExperimentalTeamsQuotedReplies public MessageActivity() : base(ActivityType.Message) { @@ -153,12 +164,58 @@ public override MessageActivity WithRecipient(Account value) } [Experimental("ExperimentalTeamsTargeted")] - #pragma warning disable ExperimentalTeamsTargeted +#pragma warning disable ExperimentalTeamsTargeted public override MessageActivity WithRecipient(Account value, bool isTargeted = false) { return (MessageActivity)base.WithRecipient(value, isTargeted); } - #pragma warning restore ExperimentalTeamsTargeted +#pragma warning restore ExperimentalTeamsTargeted + + /// + /// Add a quoted message reference and append a placeholder to text. + /// Teams renders the quoted message as a preview bubble above the response text. + /// If text is provided, it is appended to the quoted message placeholder. + /// + /// the ID of the message to quote + /// optional text, appended to the quoted message placeholder + [Experimental("ExperimentalTeamsQuotedReplies")] +#pragma warning disable ExperimentalTeamsQuotedReplies + public MessageActivity AddQuote(string messageId, string? text = null) + { + Entities ??= new List(); + Entities.Add(new QuotedReplyEntity + { + QuotedReply = new QuotedReplyData { MessageId = messageId } + }); + AddText($""); + if (text != null) + { + AddText($" {text}"); + } + return this; + } +#pragma warning restore ExperimentalTeamsQuotedReplies + + /// + /// Prepend a QuotedReply entity and placeholder before existing text. + /// Used by Reply()/Quote() for quote-above-response. + /// + [Experimental("ExperimentalTeamsQuotedReplies")] +#pragma warning disable ExperimentalTeamsQuotedReplies + public MessageActivity PrependQuote(string messageId) + { + Entities ??= new List(); + Entities.Add(new QuotedReplyEntity + { + QuotedReply = new QuotedReplyData { MessageId = messageId } + }); + var placeholder = $""; + var hasText = !string.IsNullOrWhiteSpace(Text); + Text = hasText ? $"{placeholder} {Text}" : placeholder; + return this; + } +#pragma warning restore ExperimentalTeamsQuotedReplies + public MessageActivity AddAttachment(params Attachment[] value) { @@ -209,7 +266,7 @@ public MessageActivity AddStreamFinal() { ChannelData ??= new(); ChannelData.StreamId ??= Id; - ChannelData.StreamType ??= StreamType.Final; + ChannelData.StreamType = StreamType.Final; AddEntity(new StreamInfoEntity() { diff --git a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs index b0937a57d..c95feb252 100644 --- a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs +++ b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs @@ -10,6 +10,7 @@ public class ClientCredentials : IHttpCredentials public string ClientId { get; set; } public string ClientSecret { get; set; } public string? TenantId { get; set; } + public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public; public ClientCredentials(string clientId, string clientSecret) { @@ -26,9 +27,9 @@ public ClientCredentials(string clientId, string clientSecret, string? tenantId) public async Task Resolve(IHttpClient client, string[] scopes, CancellationToken cancellationToken = default) { - var tenantId = TenantId ?? "botframework.com"; + var tenantId = TenantId ?? Cloud.LoginTenant; var request = HttpRequest.Post( - $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token" + $"{Cloud.LoginEndpoint}/{tenantId}/oauth2/v2.0/token" ); request.Headers.Add("Content-Type", ["application/x-www-form-urlencoded"]); diff --git a/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs b/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs new file mode 100644 index 000000000..ef5681590 --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Api.Auth; + +/// +/// Bundles all cloud-specific service endpoints for a given Azure environment. +/// Use predefined instances (, , , ) +/// or construct a custom one. +/// +public class CloudEnvironment +{ + /// + /// The Azure AD login endpoint (e.g. "https://login.microsoftonline.com"). + /// + public string LoginEndpoint { get; } + + /// + /// The default multi-tenant login tenant (e.g. "botframework.com"). + /// + public string LoginTenant { get; } + + /// + /// The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default"). + /// + public string BotScope { get; } + + /// + /// The Bot Framework token service base URL (e.g. "https://token.botframework.com"). + /// + public string TokenServiceUrl { get; } + + /// + /// The OpenID metadata URL for token validation (e.g. "https://login.botframework.com/v1/.well-known/openidconfiguration"). + /// + public string OpenIdMetadataUrl { get; } + + /// + /// The token issuer for Bot Framework tokens (e.g. "https://api.botframework.com"). + /// + public string TokenIssuer { get; } + + /// + /// The Microsoft Graph token scope (e.g. "https://graph.microsoft.com/.default"). + /// + public string GraphScope { get; } + + public CloudEnvironment( + string loginEndpoint, + string loginTenant, + string botScope, + string tokenServiceUrl, + string openIdMetadataUrl, + string tokenIssuer, + string graphScope) + { + LoginEndpoint = loginEndpoint.TrimEnd('/'); + LoginTenant = loginTenant; + BotScope = botScope; + TokenServiceUrl = tokenServiceUrl.TrimEnd('/'); + OpenIdMetadataUrl = openIdMetadataUrl; + TokenIssuer = tokenIssuer; + GraphScope = graphScope; + } + + /// + /// Microsoft public (commercial) cloud. + /// + public static readonly CloudEnvironment Public = new( + loginEndpoint: "https://login.microsoftonline.com", + loginTenant: "botframework.com", + botScope: "https://api.botframework.com/.default", + tokenServiceUrl: "https://token.botframework.com", + openIdMetadataUrl: "https://login.botframework.com/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.com", + graphScope: "https://graph.microsoft.com/.default" + ); + + /// + /// US Government Community Cloud High (GCCH). + /// + public static readonly CloudEnvironment USGov = new( + loginEndpoint: "https://login.microsoftonline.us", + loginTenant: "MicrosoftServices.onmicrosoft.us", + botScope: "https://api.botframework.us/.default", + tokenServiceUrl: "https://tokengcch.botframework.azure.us", + openIdMetadataUrl: "https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.us", + graphScope: "https://graph.microsoft.us/.default" + ); + + /// + /// US Government Department of Defense (DoD). + /// + public static readonly CloudEnvironment USGovDoD = new( + loginEndpoint: "https://login.microsoftonline.us", + loginTenant: "MicrosoftServices.onmicrosoft.us", + botScope: "https://api.botframework.us/.default", + tokenServiceUrl: "https://apiDoD.botframework.azure.us", + openIdMetadataUrl: "https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.us", + graphScope: "https://dod-graph.microsoft.us/.default" + ); + + /// + /// China cloud (21Vianet). + /// + public static readonly CloudEnvironment China = new( + loginEndpoint: "https://login.partner.microsoftonline.cn", + loginTenant: "microsoftservices.partner.onmschina.cn", + botScope: "https://api.botframework.azure.cn/.default", + tokenServiceUrl: "https://token.botframework.azure.cn", + openIdMetadataUrl: "https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.azure.cn", + graphScope: "https://microsoftgraph.chinacloudapi.cn/.default" + ); + + /// + /// Creates a new by applying non-null overrides on top of this instance. + /// Returns the same instance if all overrides are null (no allocation). + /// + public CloudEnvironment WithOverrides( + string? loginEndpoint = null, + string? loginTenant = null, + string? botScope = null, + string? tokenServiceUrl = null, + string? openIdMetadataUrl = null, + string? tokenIssuer = null, + string? graphScope = null) + { + if (loginEndpoint is null && loginTenant is null && botScope is null && + tokenServiceUrl is null && openIdMetadataUrl is null && tokenIssuer is null && + graphScope is null) + { + return this; + } + + return new CloudEnvironment( + loginEndpoint ?? LoginEndpoint, + loginTenant ?? LoginTenant, + botScope ?? BotScope, + tokenServiceUrl ?? TokenServiceUrl, + openIdMetadataUrl ?? OpenIdMetadataUrl, + tokenIssuer ?? TokenIssuer, + graphScope ?? GraphScope + ); + } + + /// + /// Resolves a cloud environment name (case-insensitive) to its corresponding instance. + /// Valid names: "Public", "USGov", "USGovDoD", "China". + /// + public static CloudEnvironment FromName(string name) + { + ArgumentNullException.ThrowIfNull(name); + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Cloud environment name cannot be empty or whitespace.", nameof(name)); + } + + return name.ToLowerInvariant() switch + { + "public" => Public, + "usgov" => USGov, + "usgovdod" => USGovDoD, + "china" => China, + _ => throw new ArgumentException($"Unknown cloud environment: '{name}'. Valid values are: Public, USGov, USGovDoD, China.", nameof(name)) + }; + } +} diff --git a/Libraries/Microsoft.Teams.Api/ChannelData.cs b/Libraries/Microsoft.Teams.Api/ChannelData.cs index ad3712dbb..43640f63a 100644 --- a/Libraries/Microsoft.Teams.Api/ChannelData.cs +++ b/Libraries/Microsoft.Teams.Api/ChannelData.cs @@ -63,7 +63,10 @@ public class ChannelData public App? App { get; set; } /// - /// Whether or not the feedback loop feature is enabled + /// Legacy feedback loop flag. Setting this to true is equivalent to + /// FeedbackLoop = new FeedbackLoop(FeedbackType.Default). + /// Prefer setting directly; this field is normalized + /// by . /// [JsonPropertyName("feedbackLoopEnabled")] [JsonPropertyOrder(7)] @@ -109,6 +112,17 @@ public class ChannelData [JsonPropertyOrder(14)] public MembershipSource? MembershipSource { get; set; } + /// + /// Feedback loop configuration. + /// Set Type to to trigger a + /// message/fetchTask invoke for a bot-provided task module dialog. + /// Set Type to for the standard + /// Teams thumbs up/down UI. + /// + [JsonPropertyName("feedbackLoop")] + [JsonPropertyOrder(15)] + public FeedbackLoop? FeedbackLoop { get; set; } + /// /// All extra data present /// diff --git a/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs index b1588d2ae..c61222ced 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs @@ -83,8 +83,12 @@ public ApiClient(ApiClient client, CancellationToken cancellationToken) : base(c { ServiceUrl = client.ServiceUrl; Bots = new BotClient(_http, cancellationToken); + Bots.Token.ActiveBotScope = client.Bots.Token.ActiveBotScope; + Bots.Token.ActiveGraphScope = client.Bots.Token.ActiveGraphScope; + Bots.SignIn.TokenServiceUrl = client.Bots.SignIn.TokenServiceUrl; Conversations = new ConversationClient(ServiceUrl, _http, cancellationToken); Users = new UserClient(_http, cancellationToken); + Users.Token.TokenServiceUrl = client.Users.Token.TokenServiceUrl; Teams = new TeamClient(ServiceUrl, _http, cancellationToken); Meetings = new MeetingClient(ServiceUrl, _http, cancellationToken); } diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs index 3a0a5c350..c46f89854 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs @@ -7,6 +7,8 @@ namespace Microsoft.Teams.Api.Clients; public class BotSignInClient : Client { + public string TokenServiceUrl { get; set; } = "https://token.botframework.com"; + public BotSignInClient() : base() { @@ -32,7 +34,7 @@ public async Task GetUrlAsync(GetUrlRequest request, CancellationToken c var token = cancellationToken != default ? cancellationToken : _cancellationToken; var query = QueryString.Serialize(request); var req = HttpRequest.Get( - $"https://token.botframework.com/api/botsignin/GetSignInUrl?{query}" + $"{TokenServiceUrl}/api/botsignin/GetSignInUrl?{query}" ); var res = await _http.SendAsync(req, token).ConfigureAwait(false); @@ -44,7 +46,7 @@ public async Task GetUrlAsync(GetUrlRequest request, CancellationToken c var token = cancellationToken != default ? cancellationToken : _cancellationToken; var query = QueryString.Serialize(request); var req = HttpRequest.Get( - $"https://token.botframework.com/api/botsignin/GetSignInResource?{query}" + $"{TokenServiceUrl}/api/botsignin/GetSignInResource?{query}" ); var res = await _http.SendAsync(req, token).ConfigureAwait(false); diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs index 41a800eed..9a173c293 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs @@ -9,6 +9,8 @@ public class BotTokenClient : Client { public static readonly string BotScope = "https://api.botframework.com/.default"; public static readonly string GraphScope = "https://graph.microsoft.com/.default"; + public string ActiveBotScope { get; set; } = BotScope; + public string ActiveGraphScope { get; set; } = GraphScope; public BotTokenClient() : this(default) { @@ -38,12 +40,12 @@ public BotTokenClient(IHttpClientFactory factory, CancellationToken cancellation public virtual async Task GetAsync(IHttpCredentials credentials, IHttpClient? http = null, CancellationToken cancellationToken = default) { var token = cancellationToken != default ? cancellationToken : _cancellationToken; - return await credentials.Resolve(http ?? _http, [BotScope], token).ConfigureAwait(false); + return await credentials.Resolve(http ?? _http, [ActiveBotScope], token).ConfigureAwait(false); } public async Task GetGraphAsync(IHttpCredentials credentials, IHttpClient? http = null, CancellationToken cancellationToken = default) { var token = cancellationToken != default ? cancellationToken : _cancellationToken; - return await credentials.Resolve(http ?? _http, [GraphScope], token).ConfigureAwait(false); + return await credentials.Resolve(http ?? _http, [ActiveGraphScope], token).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs index 6c3f52091..3050acd46 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs @@ -10,6 +10,8 @@ namespace Microsoft.Teams.Api.Clients; public class UserTokenClient : Client { + public string TokenServiceUrl { get; set; } = "https://token.botframework.com"; + private readonly JsonSerializerOptions _jsonSerializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull @@ -39,7 +41,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var token = cancellationToken != default ? cancellationToken : _cancellationToken; var query = QueryString.Serialize(request); - var req = HttpRequest.Get($"https://token.botframework.com/api/usertoken/GetToken?{query}"); + var req = HttpRequest.Get($"{TokenServiceUrl}/api/usertoken/GetToken?{query}"); var res = await _http.SendAsync(req, token).ConfigureAwait(false); return res.Body; } @@ -48,7 +50,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var token = cancellationToken != default ? cancellationToken : _cancellationToken; var query = QueryString.Serialize(request); - var req = HttpRequest.Post($"https://token.botframework.com/api/usertoken/GetAadTokens?{query}", body: request); + var req = HttpRequest.Post($"{TokenServiceUrl}/api/usertoken/GetAadTokens?{query}", body: request); var res = await _http.SendAsync>(req, token).ConfigureAwait(false); return res.Body; } @@ -57,7 +59,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var token = cancellationToken != default ? cancellationToken : _cancellationToken; var query = QueryString.Serialize(request); - var req = HttpRequest.Get($"https://token.botframework.com/api/usertoken/GetTokenStatus?{query}"); + var req = HttpRequest.Get($"{TokenServiceUrl}/api/usertoken/GetTokenStatus?{query}"); var res = await _http.SendAsync>(req, token).ConfigureAwait(false); return res.Body; } @@ -66,7 +68,7 @@ public async Task SignOutAsync(SignOutRequest request, CancellationToken cancell { var token = cancellationToken != default ? cancellationToken : _cancellationToken; var query = QueryString.Serialize(request); - var req = HttpRequest.Delete($"https://token.botframework.com/api/usertoken/SignOut?{query}"); + var req = HttpRequest.Delete($"{TokenServiceUrl}/api/usertoken/SignOut?{query}"); await _http.SendAsync(req, token).ConfigureAwait(false); } @@ -84,7 +86,7 @@ public async Task SignOutAsync(SignOutRequest request, CancellationToken cancell // This is required for the Bot Framework Token Service to process the request correctly. var body = JsonSerializer.Serialize(request.GetBody(), _jsonSerializerOptions); - var req = HttpRequest.Post($"https://token.botframework.com/api/usertoken/exchange?{query}", body); + var req = HttpRequest.Post($"{TokenServiceUrl}/api/usertoken/exchange?{query}", body); req.Headers.Add("Content-Type", new List() { "application/json" }); var res = await _http.SendAsync(req, token).ConfigureAwait(false); @@ -179,4 +181,4 @@ public class ExchangeTokenRequest internal TokenExchange.Request GetBody() => ExchangeRequest; } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Api/Conversation.cs b/Libraries/Microsoft.Teams.Api/Conversation.cs index 3cb0b152b..a2cb8f53a 100644 --- a/Libraries/Microsoft.Teams.Api/Conversation.cs +++ b/Libraries/Microsoft.Teams.Api/Conversation.cs @@ -64,6 +64,31 @@ public string ThreadId } } + /// + /// Construct a threaded conversation ID by appending ;messageid={messageId} + /// to the conversation ID. This is the format APX uses to route messages + /// to a specific thread in a channel. + /// + /// the conversation to thread into (e.g. 19:abc@thread.skype) + /// the thread root message ID (must be a non-zero numeric string) + /// the threaded conversation ID (e.g. 19:abc@thread.skype;messageid=123) + public static string ToThreadedConversationId(string conversationId, string messageId) + { + if (string.IsNullOrEmpty(conversationId)) + { + throw new ArgumentException("conversationId must be a non-empty string", nameof(conversationId)); + } + + if (string.IsNullOrEmpty(messageId) || !ulong.TryParse(messageId, out var parsed) || parsed == 0) + { + throw new ArgumentException($"Invalid messageId \"{messageId}\": must be a non-zero numeric value", nameof(messageId)); + } + + // Strip any existing ;messageid= suffix (mirrors APX's NormalizeConversationId) + var baseId = conversationId.Split(';')[0]; + return $"{baseId};messageid={messageId}"; + } + public object Clone() => MemberwiseClone(); public Conversation Copy() => (Conversation)Clone(); } diff --git a/Libraries/Microsoft.Teams.Api/Entities/Entity.cs b/Libraries/Microsoft.Teams.Api/Entities/Entity.cs index abbc5ac83..500b2c95c 100644 --- a/Libraries/Microsoft.Teams.Api/Entities/Entity.cs +++ b/Libraries/Microsoft.Teams.Api/Entities/Entity.cs @@ -117,6 +117,9 @@ public override bool CanConvert(Type typeToConvert) "message" or "https://schema.org/Message" => (Entity?)element.Deserialize(options), "ProductInfo" => element.Deserialize(options), "streaminfo" => element.Deserialize(options), + #pragma warning disable ExperimentalTeamsQuotedReplies + "quotedReply" => element.Deserialize(options), + #pragma warning restore ExperimentalTeamsQuotedReplies _ => null }; @@ -161,6 +164,14 @@ public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOp return; } + #pragma warning disable ExperimentalTeamsQuotedReplies + if (value is QuotedReplyEntity quotedReply) + { + JsonSerializer.Serialize(writer, quotedReply, options); + return; + } + + #pragma warning restore ExperimentalTeamsQuotedReplies JsonSerializer.Serialize(writer, value.ToJsonObject(options), options); } } diff --git a/Libraries/Microsoft.Teams.Api/Entities/QuotedReplyEntity.cs b/Libraries/Microsoft.Teams.Api/Entities/QuotedReplyEntity.cs new file mode 100644 index 000000000..b6a6e9f3a --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Entities/QuotedReplyEntity.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Api.Entities; + +[Experimental("ExperimentalTeamsQuotedReplies")] +public class QuotedReplyEntity : Entity +{ + [JsonPropertyName("quotedReply")] + [JsonPropertyOrder(3)] + public required QuotedReplyData QuotedReply { get; set; } + + public QuotedReplyEntity() : base("quotedReply") { } +} + +[Experimental("ExperimentalTeamsQuotedReplies")] +public class QuotedReplyData +{ + [JsonPropertyName("messageId")] + public required string MessageId { get; set; } + + [JsonPropertyName("senderId")] + public string? SenderId { get; set; } + + [JsonPropertyName("senderName")] + public string? SenderName { get; set; } + + [JsonPropertyName("preview")] + public string? Preview { get; set; } + + /// + /// Timestamp of the quoted message (IC3 epoch value, e.g. "1772050244572"). + /// Populated on inbound; ignored on outbound. Absent for deleted quotes. + /// + [JsonPropertyName("time")] + public string? Time { get; set; } + + [JsonPropertyName("isReplyDeleted")] + public bool? IsReplyDeleted { get; set; } + + [JsonPropertyName("validatedMessageReference")] + public bool? ValidatedMessageReference { get; set; } +} \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/FeedbackLoop.cs b/Libraries/Microsoft.Teams.Api/FeedbackLoop.cs new file mode 100644 index 000000000..294e1fb7e --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/FeedbackLoop.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +using Microsoft.Teams.Common; + +namespace Microsoft.Teams.Api; + +/// +/// The type of feedback loop. +/// Use Custom to trigger a message/fetchTask invoke so the bot +/// can return its own task module dialog. +/// Use Default for the standard Teams thumbs up/down UI. +/// +[JsonConverter(typeof(JsonConverter))] +public partial class FeedbackType(string value) : StringEnum(value) +{ + public static readonly FeedbackType Default = new("default"); + public bool IsDefault => Default.Equals(Value); + + public static readonly FeedbackType Custom = new("custom"); + public bool IsCustom => Custom.Equals(Value); +} + +/// +/// Configuration for a feedback loop on a message. +/// +public class FeedbackLoop +{ + /// + /// The type of feedback loop. + /// + [JsonPropertyName("type")] + [JsonPropertyOrder(0)] + public FeedbackType Type { get; set; } = FeedbackType.Default; + + public FeedbackLoop() { } + + public FeedbackLoop(FeedbackType type) + { + Type = type; + } +} diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Activity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Activity.cs index d11a972d3..94ce4888d 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Activity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Activity.cs @@ -18,6 +18,7 @@ public class ActivityAttribute(string? name = null, Type? type = null) : Attribu public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnActivity(this App app, Func, Task> handler) { app.Router.Register(async (context) => @@ -40,6 +41,7 @@ public static App OnActivity(this App app, Func, Cancellatio return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnActivity(this App app, Func, Task> handler) { app.Router.Register(handler); @@ -52,6 +54,7 @@ public static App OnActivity(this App app, Func, Cancellatio return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnActivity(this App app, ActivityType type, Func, Task> handler) { app.Router.Register(new Route() @@ -86,6 +89,7 @@ public static App OnActivity(this App app, ActivityType type, Func, Task> handler) { app.Router.Register(new Route() @@ -112,6 +116,7 @@ public static App OnActivity(this App app, ActivityType type, Func(this App app, Func, Task> handler) where TActivity : IActivity { app.Router.Register(new Route() @@ -146,6 +151,7 @@ public static App OnActivity(this App app, Func, return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnActivity(this App app, Func, Task> handler) where TActivity : IActivity { app.Router.Register(new Route() @@ -172,6 +178,7 @@ public static App OnActivity(this App app, Func, return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnActivity(this App app, Func select, Func, Task> handler) { app.Router.Register(new Route() @@ -206,6 +213,7 @@ public static App OnActivity(this App app, Func select, Func select, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/CommandActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/CommandActivity.cs index 80d3f8cfd..71f4f2f45 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/CommandActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/CommandActivity.cs @@ -14,6 +14,7 @@ public class CommandAttribute() : ActivityAttribute(ActivityType.Command, type: public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnCommand(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/CommandResultActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/CommandResultActivity.cs index 24dba7b11..6c61a525c 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/CommandResultActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/CommandResultActivity.cs @@ -14,6 +14,7 @@ public class CommandResultAttribute() : ActivityAttribute(ActivityType.CommandRe public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnCommandResult(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelCreatedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelCreatedActivity.cs index a6e68df88..a940dde1c 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelCreatedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelCreatedActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnChannelCreated(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelDeletedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelDeletedActivity.cs index 8a6592677..f238b52c4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelDeletedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelDeletedActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnChannelDeleted(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberAddedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberAddedActivity.cs index f9bedce1d..e80cfcc07 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberAddedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberAddedActivity.cs @@ -22,6 +22,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnChannelMemberAdded(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberRemovedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberRemovedActivity.cs index d0db53dd0..bea4648c1 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberRemovedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelMemberRemovedActivity.cs @@ -22,6 +22,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnChannelMemberRemoved(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRenamedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRenamedActivity.cs index 50e8e28f4..e3abe935b 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRenamedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRenamedActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnChannelRenamed(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRestoredActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRestoredActivity.cs index fb35c10b0..563a96b62 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRestoredActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelRestoredActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnChannelRestored(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelSharedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelSharedActivity.cs index 574fca7eb..5fe63cfd4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelSharedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelSharedActivity.cs @@ -22,6 +22,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnChannelShared(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelUnsharedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelUnsharedActivity.cs index 62c351361..82aa08d95 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelUnsharedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ChannelUnsharedActivity.cs @@ -22,6 +22,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnChannelUnShared(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationEndActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationEndActivity.cs index 541712ec2..ef1499394 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationEndActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationEndActivity.cs @@ -17,6 +17,7 @@ public class EndAttribute() : ActivityAttribute(ActivityType.EndOfConversation, public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnConversationEnd(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationUpdateActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationUpdateActivity.cs index 91f57d4ea..30afd9be1 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationUpdateActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/ConversationUpdateActivity.cs @@ -27,6 +27,7 @@ public UpdateAttribute(ConversationUpdateActivity.EventType eventType) : base(st public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnConversationUpdate(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersAddedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersAddedActivity.cs index 9ff156cb7..2d69e41a2 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersAddedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersAddedActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMembersAdded(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersRemovedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersRemovedActivity.cs index 7245a9ad4..6faaa9ba9 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersRemovedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/MembersRemovedActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMembersRemoved(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamArchivedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamArchivedActivity.cs index 3e92fbca7..4dcda34e9 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamArchivedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamArchivedActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTeamArchived(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamDeletedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamDeletedActivity.cs index fd895f986..dc6822318 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamDeletedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamDeletedActivity.cs @@ -27,6 +27,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTeamDeleted(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -52,6 +53,7 @@ public static App OnTeamDeleted(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRenamedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRenamedActivity.cs index ce17b6076..eadb4dd67 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRenamedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRenamedActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTeamRenamed(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRestoredActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRestoredActivity.cs index d4a7db247..c1ed9ccf7 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRestoredActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamRestoredActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTeamRestored(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamUnArchivedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamUnArchivedActivity.cs index 69676cac3..10669c9a1 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamUnArchivedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Conversations/TeamUnArchivedActivity.cs @@ -25,6 +25,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTeamUnArchived(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/EventActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/EventActivity.cs index 5aae450e8..a64e1e21b 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/EventActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/EventActivity.cs @@ -25,6 +25,7 @@ public EventAttribute(Name name) : base(string.Join("/", [ActivityType.Event, na public static partial class AppEventActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnEvent(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingEndActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingEndActivity.cs index f7b625ad0..303787ea4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingEndActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingEndActivity.cs @@ -27,6 +27,7 @@ public override bool Select(IActivity activity) public static partial class AppEventActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMeetingEnd(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingJoinActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingJoinActivity.cs index 715792cf1..363effe5d 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingJoinActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingJoinActivity.cs @@ -27,6 +27,7 @@ public override bool Select(IActivity activity) public static partial class AppEventActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMeetingJoin(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingLeaveActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingLeaveActivity.cs index 02eca4589..661658f3a 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingLeaveActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingLeaveActivity.cs @@ -27,6 +27,7 @@ public override bool Select(IActivity activity) public static partial class AppEventActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMeetingLeave(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingStartActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingStartActivity.cs index ede80be6d..aaad370d0 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingStartActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/MeetingStartActivity.cs @@ -27,6 +27,7 @@ public override bool Select(IActivity activity) public static partial class AppEventActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMeetingStart(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Events/ReadReceiptActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Events/ReadReceiptActivity.cs index 511fe6355..68e0c3f62 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Events/ReadReceiptActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Events/ReadReceiptActivity.cs @@ -27,6 +27,7 @@ public override bool Select(IActivity activity) public static partial class AppEventActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnReadReceipt(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallActivity.cs index 78b8779f2..690de22cb 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallActivity.cs @@ -22,6 +22,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnInstall(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallUpdateActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallUpdateActivity.cs index 6c255157b..1d2afd0cd 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallUpdateActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Installs/InstallUpdateActivity.cs @@ -24,6 +24,7 @@ public InstallUpdateAttribute(InstallUpdateAction action) : base(string.Join("/" public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnInstallUpdate(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Installs/UnInstallActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Installs/UnInstallActivity.cs index d5aa9a1c1..65a246de4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Installs/UnInstallActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Installs/UnInstallActivity.cs @@ -22,6 +22,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnUnInstall(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/AdaptiveCards/ActionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/AdaptiveCards/ActionActivity.cs index 0d837b278..d9d6e805e 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/AdaptiveCards/ActionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/AdaptiveCards/ActionActivity.cs @@ -19,6 +19,7 @@ public class ActionAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Ada public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnAdaptiveCardAction(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -36,6 +37,7 @@ public static App OnAdaptiveCardAction(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -49,6 +51,7 @@ public static App OnAdaptiveCardAction(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/FetchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/FetchActivity.cs index 93d389f26..2deabfbca 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/FetchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/FetchActivity.cs @@ -19,6 +19,7 @@ public class FetchAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Conf public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnConfigFetch(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -36,6 +37,7 @@ public static App OnConfigFetch(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -49,6 +51,7 @@ public static App OnConfigFetch(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/SubmitActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/SubmitActivity.cs index 7acf38eb4..7c9554492 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/SubmitActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Configs/SubmitActivity.cs @@ -19,6 +19,7 @@ public class SubmitAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Con public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnConfigSubmit(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -36,6 +37,7 @@ public static App OnConfigSubmit(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -49,6 +51,7 @@ public static App OnConfigSubmit(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/ExecuteActionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/ExecuteActionActivity.cs index 498caf15e..4694741a4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/ExecuteActionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/ExecuteActionActivity.cs @@ -15,6 +15,7 @@ public class ExecuteActionAttribute() : InvokeAttribute(Api.Activities.Invokes.N public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnExecuteAction(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -32,6 +33,7 @@ public static App OnExecuteAction(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/FileConsentActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/FileConsentActivity.cs index d7f3406c3..bac18075a 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/FileConsentActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/FileConsentActivity.cs @@ -15,6 +15,7 @@ public class FileConsentAttribute() : InvokeAttribute(Api.Activities.Invokes.Nam public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnFileConsent(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -32,6 +33,7 @@ public static App OnFileConsent(this App app, Func return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnFileConsent(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/HandoffActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/HandoffActivity.cs index e361376ca..4795604bf 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/HandoffActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/HandoffActivity.cs @@ -15,6 +15,7 @@ public class HandoffAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Ha public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnHandoff(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -32,6 +33,7 @@ public static App OnHandoff(this App app, Func, Task> return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnHandoff(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/InvokeActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/InvokeActivity.cs index 353b3db91..1aa107cf6 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/InvokeActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/InvokeActivity.cs @@ -31,6 +31,7 @@ public override bool Select(IActivity activity) public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnInvoke(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -48,6 +49,7 @@ public static App OnInvoke(this App app, Func, Task> ha return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnInvoke(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -61,6 +63,7 @@ public static App OnInvoke(this App app, Func, Task, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/AnonQueryLinkActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/AnonQueryLinkActivity.cs index d1e6cc408..4c3ca81cd 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/AnonQueryLinkActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/AnonQueryLinkActivity.cs @@ -18,6 +18,7 @@ public class AnonQueryLinkAttribute() : InvokeAttribute(Api.Activities.Invokes.N public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnAnonQueryLink(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnAnonQueryLink(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnAnonQueryLink(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/CardButtonClickedActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/CardButtonClickedActivity.cs index c36ae0ce6..56a6bb2f7 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/CardButtonClickedActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/CardButtonClickedActivity.cs @@ -18,6 +18,7 @@ public class CardButtonClickedAttribute() : InvokeAttribute(Api.Activities.Invok public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnCardButtonClicked(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/FetchTaskActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/FetchTaskActivity.cs index e6fad7716..0bb12965a 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/FetchTaskActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/FetchTaskActivity.cs @@ -18,6 +18,7 @@ public class FetchTaskAttribute() : InvokeAttribute(Api.Activities.Invokes.Name. public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnFetchTask(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnFetchTask(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnFetchTask(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryActivity.cs index abd60c0c3..2c8e014b4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryActivity.cs @@ -18,6 +18,7 @@ public class QueryAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Mess public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnQuery(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnQuery(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnQuery(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryLinkActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryLinkActivity.cs index 0d0659d06..a7374a1e7 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryLinkActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QueryLinkActivity.cs @@ -18,6 +18,7 @@ public class QueryLinkAttribute() : InvokeAttribute(Api.Activities.Invokes.Name. public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnQueryLink(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnQueryLink(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnQueryLink(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QuerySettingsUrlActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QuerySettingsUrlActivity.cs index 9e89ff05f..6d337cd53 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QuerySettingsUrlActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/QuerySettingsUrlActivity.cs @@ -18,6 +18,7 @@ public class QuerySettingsUrlAttribute() : InvokeAttribute(Api.Activities.Invoke public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnQuerySettingsUrl(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnQuerySettingsUrl(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnQuerySettingsUrl(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SelectItemActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SelectItemActivity.cs index 82941368a..8d68228f9 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SelectItemActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SelectItemActivity.cs @@ -18,6 +18,7 @@ public class SelectItemAttribute() : InvokeAttribute(Api.Activities.Invokes.Name public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnSelectItem(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnSelectItem(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnSelectItem(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SettingsActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SettingsActivity.cs index dc18ffdd4..e0c4c6a6b 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SettingsActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SettingsActivity.cs @@ -18,6 +18,7 @@ public class SettingAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Me public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnSetting(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SubmitActionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SubmitActionActivity.cs index 4b8fafacb..db588175a 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SubmitActionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/MessageExtensions/SubmitActionActivity.cs @@ -18,6 +18,7 @@ public class SubmitActionAttribute() : InvokeAttribute(Api.Activities.Invokes.Na public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnSubmitAction(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnSubmitAction(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnSubmitAction(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FeedbackActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FeedbackActivity.cs index bc16d9c7d..b3cc05dc8 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FeedbackActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FeedbackActivity.cs @@ -29,6 +29,7 @@ public static partial class AppInvokeActivityExtensions /// /// Registers a handler for message feedback activities /// + [Obsolete("Use the handler with the cancellation token")] public static App OnFeedback(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FetchTaskActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FetchTaskActivity.cs new file mode 100644 index 000000000..f2fa35ea2 --- /dev/null +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FetchTaskActivity.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Activities.Invokes; +using Microsoft.Teams.Apps.Routing; + +namespace Microsoft.Teams.Apps.Activities.Invokes; + +public static partial class Message +{ + [AttributeUsage(AttributeTargets.Method, Inherited = true)] + public class FetchTaskAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Messages.FetchTask, typeof(Messages.FetchTaskActivity)) + { + public override object Coerce(IContext context) => context.ToActivityType(); + } +} + +public static partial class AppInvokeActivityExtensions +{ + /// + /// Registers a handler for message/fetchTask activities. + /// The bot should return a task module response containing the dialog to show the user. + /// + public static App OnMessageFetchTask(this App app, Func, CancellationToken, Task> handler) + { + app.Router.Register(new Route() + { + Name = string.Join("/", [ActivityType.Invoke, Name.Messages.FetchTask]), + Type = app.Status is null ? RouteType.System : RouteType.User, + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Selector = activity => activity is Messages.FetchTaskActivity + }); + + return app; + } +} diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/SubmitActionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/SubmitActionActivity.cs index e726dad21..1c5579917 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/SubmitActionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/SubmitActionActivity.cs @@ -18,6 +18,7 @@ public class SubmitActionAttribute() : InvokeAttribute(Api.Activities.Invokes.Na public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnSubmitAction(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnSubmitAction(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/AnswerSearchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/AnswerSearchActivity.cs index cf8f8e305..5dbac2182 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/AnswerSearchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/AnswerSearchActivity.cs @@ -24,6 +24,7 @@ public override bool Select(IActivity activity) public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnAnswerSearch(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -49,6 +50,7 @@ public static App OnAnswerSearch(this App app, Func, Ta return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnAnswerSearch(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -70,6 +72,7 @@ public static App OnAnswerSearch(this App app, Func, Ta return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnAnswerSearch(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/SearchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/SearchActivity.cs index 437a6e8f0..70158a6b8 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/SearchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/SearchActivity.cs @@ -13,6 +13,7 @@ public class SearchAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Sea public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnSearch(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -30,6 +31,7 @@ public static App OnSearch(this App app, Func, Task> ha return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnSearch(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -43,6 +45,7 @@ public static App OnSearch(this App app, Func, Task, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/TypeaheadSearchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/TypeaheadSearchActivity.cs index 0e29291aa..6222a5393 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/TypeaheadSearchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Search/TypeaheadSearchActivity.cs @@ -24,6 +24,7 @@ public override bool Select(IActivity activity) public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTypeaheadSearch(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -49,6 +50,7 @@ public static App OnTypeaheadSearch(this App app, Func, return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTypeaheadSearch(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -70,6 +72,7 @@ public static App OnTypeaheadSearch(this App app, Func, return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTypeaheadSearch(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs index 9649225fe..ec41e8cea 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs @@ -21,6 +21,7 @@ public static partial class AppInvokeActivityExtensions /// /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails. /// + [Obsolete("Use the handler with the cancellation token")] public static App OnSignInFailure(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -41,6 +42,7 @@ public static App OnSignInFailure(this App app, Func /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails. /// + [Obsolete("Use the handler with the cancellation token")] public static App OnSignInFailure(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -57,6 +59,7 @@ public static App OnSignInFailure(this App app, Func /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails. /// + [Obsolete("Use the handler with the cancellation token")] public static App OnSignInFailure(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/TokenExchangeActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/TokenExchangeActivity.cs index daf4fb369..9c77a5c06 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/TokenExchangeActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/TokenExchangeActivity.cs @@ -15,6 +15,7 @@ public class TokenExchangeAttribute() : InvokeAttribute(Api.Activities.Invokes.N public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTokenExchange(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -32,6 +33,7 @@ public static App OnTokenExchange(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -45,6 +47,7 @@ public static App OnTokenExchange(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -58,6 +61,7 @@ public static App OnTokenExchange(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/VerifyStateAcitivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/VerifyStateAcitivity.cs index 8d004317e..79d30dffd 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/VerifyStateAcitivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/VerifyStateAcitivity.cs @@ -15,6 +15,7 @@ public class VerifyStateAttribute() : InvokeAttribute(Api.Activities.Invokes.Nam public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnVerifyState(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -32,6 +33,7 @@ public static App OnVerifyState(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -45,6 +47,7 @@ public static App OnVerifyState(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/FetchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/FetchActivity.cs index a2b81454c..58205aead 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/FetchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/FetchActivity.cs @@ -18,6 +18,7 @@ public class FetchAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Tabs public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTabFetch(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnTabFetch(this App app, Func, Ta return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTabFetch(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnTabFetch(this App app, Func, Ta return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTabFetch(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/SubmitActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/SubmitActivity.cs index 359c8d8d4..c10c614b4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/SubmitActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tabs/SubmitActivity.cs @@ -18,6 +18,7 @@ public class SubmitAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Tab public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTabSubmit(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -35,6 +36,7 @@ public static App OnTabSubmit(this App app, Func, return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTabSubmit(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -48,6 +50,7 @@ public static App OnTabSubmit(this App app, Func, return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTabSubmit(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/FetchActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/FetchActivity.cs index 5e8d4ac64..614bbdb4b 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/FetchActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/FetchActivity.cs @@ -15,6 +15,7 @@ public class TaskFetchAttribute() : InvokeAttribute(Api.Activities.Invokes.Name. public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTaskFetch(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -32,6 +33,7 @@ public static App OnTaskFetch(this App app, Func, return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTaskFetch(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -45,6 +47,7 @@ public static App OnTaskFetch(this App app, Func, return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTaskFetch(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/SubmitActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/SubmitActivity.cs index d4e0d08b1..71c15335c 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/SubmitActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Tasks/SubmitActivity.cs @@ -15,6 +15,7 @@ public class TaskSubmitAttribute() : InvokeAttribute(Api.Activities.Invokes.Name public static partial class AppInvokeActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTaskSubmit(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -32,6 +33,7 @@ public static App OnTaskSubmit(this App app, Func return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTaskSubmit(this App app, Func, Task>> handler) { app.Router.Register(new Route() @@ -45,6 +47,7 @@ public static App OnTaskSubmit(this App app, Func return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnTaskSubmit(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageActivity.cs index b2e573f2f..400edb7d8 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageActivity.cs @@ -36,6 +36,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMessage(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -70,6 +71,7 @@ public static App OnMessage(this App app, Func, Cancel return app; } + [Obsolete("Use the handler with the cancellation token")] public static App OnMessage(this App app, string pattern, Func, Task> handler) { app.Router.Register(new Route() @@ -121,6 +123,7 @@ public static App OnMessage(this App app, string pattern, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageDeleteActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageDeleteActivity.cs index 30cbc365c..e0aff7741 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageDeleteActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageDeleteActivity.cs @@ -17,6 +17,7 @@ public class DeleteAttribute() : ActivityAttribute(ActivityType.MessageDelete, t public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMessageDelete(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageReactionActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageReactionActivity.cs index 53aa04ea0..94f195960 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageReactionActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageReactionActivity.cs @@ -45,6 +45,7 @@ public override bool Select(IActivity activity) public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMessageReaction(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -79,6 +80,7 @@ public static App OnMessageReaction(this App app, Func, Task> handler) { app.Router.Register(new Route() @@ -129,6 +131,7 @@ public static App OnMessageReactionAdded(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageUpdateActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageUpdateActivity.cs index 323e5a55c..75ba16c0b 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageUpdateActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/Messages/MessageUpdateActivity.cs @@ -17,6 +17,7 @@ public class UpdateAttribute() : ActivityAttribute(ActivityType.MessageUpdate, t public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnMessageUpdate(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/Activities/TypingActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/TypingActivity.cs index 43831a1c6..f846e4cb4 100644 --- a/Libraries/Microsoft.Teams.Apps/Activities/TypingActivity.cs +++ b/Libraries/Microsoft.Teams.Apps/Activities/TypingActivity.cs @@ -14,6 +14,7 @@ public class TypingAttribute() : ActivityAttribute(ActivityType.Typing, typeof(T public static partial class AppActivityExtensions { + [Obsolete("Use the handler with the cancellation token")] public static App OnTyping(this App app, Func, Task> handler) { app.Router.Register(new Route() diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index 7f7374a96..b8c0e2251 100644 --- a/Libraries/Microsoft.Teams.Apps/App.cs +++ b/Libraries/Microsoft.Teams.Apps/App.cs @@ -37,9 +37,17 @@ public partial class App public IToken? Token { get; internal set; } public OAuthSettings OAuth { get; internal set; } + /// + /// When true, performs a per-activity user OAuth token lookup to populate + /// IContext.IsSignedIn / IContext.UserGraphToken. Set to false to + /// skip the call when SSO is not configured. Defaults to true. + /// + public bool AutoUserTokenLookup { get; internal set; } + internal IHttpClient TokenClient { get; set; } internal IServiceProvider? Provider { get; set; } internal IContainer Container { get; set; } + internal string UserAgent { get @@ -51,11 +59,14 @@ internal string UserAgent public App(AppOptions? options = null) { + var cloud = options?.Cloud ?? CloudEnvironment.Public; + Logger = options?.Logger ?? new ConsoleLogger(); Storage = options?.Storage ?? new LocalStorage(); Credentials = options?.Credentials; Plugins = options?.Plugins ?? []; OAuth = options?.OAuth ?? new OAuthSettings(); + AutoUserTokenLookup = options?.AutoUserTokenLookup ?? true; Provider = options?.Provider; TokenClient = new Common.Http.HttpClient(); @@ -77,7 +88,7 @@ public App(AppOptions? options = null) if (Token.IsExpired) { - var res = Credentials.Resolve(TokenClient, [.. Token.Scopes.DefaultIfEmpty(BotTokenClient.BotScope)]) + var res = Credentials.Resolve(TokenClient, [.. Token.Scopes.DefaultIfEmpty(Api!.Bots.Token.ActiveBotScope)]) .ConfigureAwait(false) .GetAwaiter() .GetResult(); @@ -90,6 +101,10 @@ public App(AppOptions? options = null) }; Api = new ApiClient("https://smba.trafficmanager.net/teams/", Client); + Api.Bots.Token.ActiveBotScope = cloud.BotScope; + Api.Bots.Token.ActiveGraphScope = cloud.GraphScope; + Api.Bots.SignIn.TokenServiceUrl = cloud.TokenServiceUrl; + Api.Users.Token.TokenServiceUrl = cloud.TokenServiceUrl; Container = new Container(); Container.Register(Logger); Container.Register(Storage); @@ -167,9 +182,12 @@ await Events.Emit( } /// - /// send an activity to the conversation + /// send an activity proactively to a conversation. + /// Sends to the exact conversation ID provided. For channel threads, + /// the conversation ID must include ;messageid= -- use + /// to construct it, or use + /// which handles this automatically. /// - /// activity activity to send public async Task Send(string conversationId, T activity, ConversationType? conversationType = null, string? serviceUrl = null, CancellationToken cancellationToken = default) where T : IActivity { if (Id is null) @@ -190,7 +208,7 @@ public async Task Send(string conversationId, T activity, ConversationType Conversation = new() { Id = conversationId, - Type = conversationType ?? ConversationType.Personal + Type = conversationType } }; @@ -231,6 +249,67 @@ public async Task Send(string conversationId, Cards.AdaptiveCar return await Send(conversationId, new MessageActivity().AddAttachment(card), conversationType, serviceUrl, cancellationToken).ConfigureAwait(false); } + /// + /// send an activity proactively to a conversation, optionally as a threaded reply. + /// Constructs a threaded conversation ID from the conversation ID + /// and message ID via , + /// then sends to that thread. The service determines whether threading is + /// supported for the given conversation type. + /// + /// the conversation ID + /// the thread root message ID + /// the activity to send + /// optional cancellation token + public Task Reply(string conversationId, string messageId, T activity, CancellationToken cancellationToken = default) where T : IActivity + { + return Send(Conversation.ToThreadedConversationId(conversationId, messageId), activity, cancellationToken: cancellationToken); + } + + /// + /// send an activity proactively to a conversation. + /// Sends to the exact conversation ID provided - threaded if + /// it contains ;messageid=, flat otherwise. + /// + /// the conversation to send to + /// the activity to send + /// optional cancellation token + public Task Reply(string conversationId, T activity, CancellationToken cancellationToken = default) where T : IActivity + { + return Send(conversationId, activity, cancellationToken: cancellationToken); + } + + /// + /// send a message proactively to a thread + /// + public Task Reply(string conversationId, string messageId, string text, CancellationToken cancellationToken = default) + { + return Reply(conversationId, messageId, new MessageActivity(text), cancellationToken); + } + + /// + /// send a message proactively to a conversation + /// + public Task Reply(string conversationId, string text, CancellationToken cancellationToken = default) + { + return Reply(conversationId, new MessageActivity(text), cancellationToken); + } + + /// + /// send a card proactively to a thread + /// + public Task Reply(string conversationId, string messageId, Cards.AdaptiveCard card, CancellationToken cancellationToken = default) + { + return Reply(conversationId, messageId, new MessageActivity().AddAttachment(card), cancellationToken); + } + + /// + /// send a card proactively to a conversation + /// + public Task Reply(string conversationId, Cards.AdaptiveCard card, CancellationToken cancellationToken = default) + { + return Reply(conversationId, new MessageActivity().AddAttachment(card), cancellationToken); + } + /// /// process an activity /// @@ -289,28 +368,30 @@ private async Task Process(ISenderPlugin sender, ActivityEvent @event, var api = new ApiClient(Api, cancellationToken); - try + if (AutoUserTokenLookup) { - var tokenResponse = await api.Users.Token.GetAsync(new() + try { - UserId = @event.Activity.From.Id, - ChannelId = @event.Activity.ChannelId, - ConnectionName = OAuth.DefaultConnectionName - }).ConfigureAwait(false); + var tokenResponse = await api.Users.Token.GetAsync(new() + { + UserId = @event.Activity.From.Id, + ChannelId = @event.Activity.ChannelId, + ConnectionName = OAuth.DefaultConnectionName + }, cancellationToken).ConfigureAwait(false); - userToken = new JsonWebToken(tokenResponse); - } - catch (Exception ex) - { - Logger.Debug("Token retrieval failed, proceeding without token", ex); + userToken = new JsonWebToken(tokenResponse); + } + catch { } } var path = @event.Activity.GetPath(); Logger.Debug(path); + var serviceUrl = @event.Activity.ServiceUrl ?? @event.Token.ServiceUrl; + var reference = new ConversationReference() { - ServiceUrl = @event.Activity.ServiceUrl ?? @event.Token.ServiceUrl, + ServiceUrl = serviceUrl, ChannelId = @event.Activity.ChannelId, Bot = @event.Activity.Recipient, User = @event.Activity.From, @@ -404,4 +485,4 @@ await Events.Emit( return response; } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Apps/AppBuilder.cs b/Libraries/Microsoft.Teams.Apps/AppBuilder.cs index 63e5d6f5f..05384c85b 100644 --- a/Libraries/Microsoft.Teams.Apps/AppBuilder.cs +++ b/Libraries/Microsoft.Teams.Apps/AppBuilder.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Microsoft.Teams.Api.Auth; using Microsoft.Teams.Apps.Plugins; namespace Microsoft.Teams.Apps; @@ -98,6 +99,23 @@ public AppBuilder AddOAuth(string defaultConnectionName) return this; } + public AppBuilder AddCloud(CloudEnvironment cloud) + { + _options.Cloud = cloud; + return this; + } + + /// + /// When true, performs a per-activity user OAuth token lookup to populate + /// IContext.IsSignedIn / IContext.UserGraphToken. Set to false to + /// skip the call when SSO is not configured. Defaults to true. + /// + public AppBuilder AutoUserTokenLookup(bool enabled) + { + _options.AutoUserTokenLookup = enabled; + return this; + } + public App Build() { return new App(_options); diff --git a/Libraries/Microsoft.Teams.Apps/AppEvents.cs b/Libraries/Microsoft.Teams.Apps/AppEvents.cs index add73af15..b7cc0269d 100644 --- a/Libraries/Microsoft.Teams.Apps/AppEvents.cs +++ b/Libraries/Microsoft.Teams.Apps/AppEvents.cs @@ -27,9 +27,8 @@ protected async Task OnErrorEvent(IPlugin sender, ErrorEvent @event, Cancellatio } } - foreach (var plugin in Plugins) + foreach (var plugin in Plugins.Where(p => !ReferenceEquals(sender, p))) { - if (sender.Equals(plugin)) continue; await plugin.OnError(this, sender, @event, cancellationToken).ConfigureAwait(false); } } @@ -44,9 +43,8 @@ protected async Task OnActivitySentEvent(ISenderPlugin sender, ActivitySentEvent { Logger.Debug(EventType.ActivitySent); - foreach (var plugin in Plugins) + foreach (var plugin in Plugins.Where(p => !ReferenceEquals(sender, p))) { - if (sender.Equals(plugin)) continue; await plugin.OnActivitySent(this, sender, @event, cancellationToken).ConfigureAwait(false); } } @@ -55,10 +53,9 @@ protected async Task OnActivityResponseEvent(ISenderPlugin sender, ActivityRespo { Logger.Debug(EventType.ActivityResponse); - foreach (var plugin in Plugins) + foreach (var plugin in Plugins.Where(p => !ReferenceEquals(sender, p))) { - if (sender.Equals(plugin)) continue; await plugin.OnActivityResponse(this, sender, @event, cancellationToken).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Apps/AppOptions.cs b/Libraries/Microsoft.Teams.Apps/AppOptions.cs index b923afa21..57906be89 100644 --- a/Libraries/Microsoft.Teams.Apps/AppOptions.cs +++ b/Libraries/Microsoft.Teams.Apps/AppOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Microsoft.Teams.Api.Auth; using Microsoft.Teams.Apps.Plugins; namespace Microsoft.Teams.Apps; @@ -15,6 +16,14 @@ public class AppOptions public Common.Http.IHttpCredentials? Credentials { get; set; } public IList Plugins { get; set; } = []; public OAuthSettings OAuth { get; set; } = new OAuthSettings(); + public CloudEnvironment? Cloud { get; set; } + + /// + /// When true, performs a per-activity user OAuth token lookup to populate + /// IContext.IsSignedIn / IContext.UserGraphToken. Set to false to + /// skip the call when SSO is not configured. Defaults to true. + /// + public bool AutoUserTokenLookup { get; set; } = true; public AppOptions() { diff --git a/Libraries/Microsoft.Teams.Apps/AppRouting.cs b/Libraries/Microsoft.Teams.Apps/AppRouting.cs index bf05390db..4d062ab7d 100644 --- a/Libraries/Microsoft.Teams.Apps/AppRouting.cs +++ b/Libraries/Microsoft.Teams.Apps/AppRouting.cs @@ -66,7 +66,7 @@ public App AddController(T controller) where T : class return this; } - protected async Task OnTokenExchangeActivity(IContext context) + protected async Task OnTokenExchangeActivity(IContext context, CancellationToken cancellationToken = default) { var connectionName = context.Activity.Value.ConnectionName; @@ -83,7 +83,7 @@ protected async Task OnTokenExchangeActivity(IContext(), Token = res - } + }, + cancellationToken ).ConfigureAwait(false); return new Response(HttpStatusCode.OK); @@ -109,7 +110,7 @@ await Events.Emit( Exception = ex, Context = context.ToActivityType() }, - context.CancellationToken + cancellationToken ).ConfigureAwait(false); if (ex.StatusCode != HttpStatusCode.NotFound && ex.StatusCode != HttpStatusCode.BadRequest && ex.StatusCode != HttpStatusCode.PreconditionFailed) @@ -126,7 +127,7 @@ await Events.Emit( } } - protected async Task OnVerifyStateActivity(IContext context) + protected async Task OnVerifyStateActivity(IContext context, CancellationToken cancellationToken = default) { try { @@ -142,7 +143,7 @@ await Events.Emit( UserId = context.Activity.From.Id, ConnectionName = OAuth.DefaultConnectionName, Code = context.Activity.Value.State - }).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); context.UserGraphToken = new JsonWebToken(res); @@ -153,7 +154,8 @@ await Events.Emit( { Context = context.ToActivityType(), Token = res - } + }, + cancellationToken ).ConfigureAwait(false); return new Response(HttpStatusCode.OK); } @@ -198,7 +200,7 @@ await Events.Emit( /// interactionrequiredUser interaction is required (handled via OAuth card fallback, does not typically reach the bot). /// /// - protected async Task OnSignInFailureActivity(IContext context) + protected async Task OnSignInFailureActivity(IContext context, CancellationToken cancellationToken = default) { var failure = context.Activity.Value; @@ -218,7 +220,7 @@ await Events.Emit( Exception = new Exception($"Sign-in failure: {failure.Code} — {failure.Message}"), Context = context.ToActivityType() }, - context.CancellationToken + cancellationToken ).ConfigureAwait(false); return new Response(HttpStatusCode.OK); @@ -254,4 +256,4 @@ public App Use(Func, Task> handler) return null; }); } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs index 4310fbd94..64804863c 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.Send.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; + using Microsoft.Teams.Api.Activities; namespace Microsoft.Teams.Apps; @@ -8,7 +10,10 @@ namespace Microsoft.Teams.Apps; public partial interface IContext { /// - /// send an activity to the conversation + /// send an activity in the current conversation without quoting. + /// In channels, sends to the current thread. In scopes that do not + /// support threading (group chat, meetings), sends as a normal message. + /// To send with a visual quote of the inbound message, use . /// /// activity activity to send /// optional cancellation token @@ -29,26 +34,49 @@ public partial interface IContext public Task Send(Cards.AdaptiveCard card, CancellationToken cancellationToken = default); /// - /// send an activity to the conversation as a reply + /// send an activity in the current conversation with a visual quote + /// of the inbound message. In channels, sends to the current thread + /// with a quoted reply. In other scopes, sends with a quoted reply. + /// To send without quoting, use . /// - /// activity activity to send + /// activity to send /// optional cancellation token public Task Reply(T activity, CancellationToken cancellationToken = default) where T : IActivity; /// - /// send a message activity to the conversation as a reply + /// send a message activity to the conversation as a reply, automatically quoting the inbound message /// /// the text to send /// optional cancellation token public Task Reply(string text, CancellationToken cancellationToken = default); /// - /// send a message activity with a card attachment as a reply + /// send a message activity with a card attachment as a reply, automatically quoting the inbound message /// /// the card to send as an attachment /// optional cancellation token public Task Reply(Cards.AdaptiveCard card, CancellationToken cancellationToken = default); + /// + /// Send a message to the conversation with a quoted message reference prepended to the text. + /// Teams renders the quoted message as a preview bubble above the response text. + /// + /// the ID of the message to quote + /// the activity to send — a quote placeholder for messageId will be prepended to its text + /// optional cancellation token + [Experimental("ExperimentalTeamsQuotedReplies")] + public Task Quote(string messageId, T activity, CancellationToken cancellationToken = default) where T : IActivity; + + /// + /// Send a message to the conversation with a quoted message reference prepended to the text. + /// Teams renders the quoted message as a preview bubble above the response text. + /// + /// the ID of the message to quote + /// the response text, appended to the quoted message placeholder + /// optional cancellation token + [Experimental("ExperimentalTeamsQuotedReplies")] + public Task Quote(string messageId, string text, CancellationToken cancellationToken = default); + /// /// send a typing activity /// @@ -76,21 +104,17 @@ public Task Send(Cards.AdaptiveCard card, CancellationToken can return Send(new MessageActivity().AddAttachment(card), cancellationToken); } +#pragma warning disable ExperimentalTeamsQuotedReplies public Task Reply(T activity, CancellationToken cancellationToken = default) where T : IActivity { - activity.Conversation = Ref.Conversation.Copy(); - activity.Conversation.Id = Ref.Conversation.ThreadId; - - if (activity is MessageActivity message) + if (Activity.Id != null) { - message.Text = string.Join("\n", [ - Activity.ToQuoteReply(), - message.Text != string.Empty ? $"

{message.Text}

" : string.Empty - ]); + return Quote(Activity.Id, activity, cancellationToken); } return Send(activity, cancellationToken); } +#pragma warning restore ExperimentalTeamsQuotedReplies public Task Reply(string text, CancellationToken cancellationToken = default) { @@ -102,6 +126,25 @@ public Task Reply(Cards.AdaptiveCard card, CancellationToken ca return Reply(new MessageActivity().AddAttachment(card), cancellationToken); } + [Experimental("ExperimentalTeamsQuotedReplies")] +#pragma warning disable ExperimentalTeamsQuotedReplies + public Task Quote(string messageId, T activity, CancellationToken cancellationToken = default) where T : IActivity + { + if (activity is MessageActivity message) + { + message.PrependQuote(messageId); + } + + return Send(activity, cancellationToken); + } +#pragma warning restore ExperimentalTeamsQuotedReplies + + [Experimental("ExperimentalTeamsQuotedReplies")] + public Task Quote(string messageId, string text, CancellationToken cancellationToken = default) + { + return Quote(messageId, new MessageActivity(text), cancellationToken); + } + public Task Typing(string? text = null, CancellationToken cancellationToken = default) { var activity = new TypingActivity(); diff --git a/Libraries/Microsoft.Teams.Cards/Actions/IMBackAction.cs b/Libraries/Microsoft.Teams.Cards/Actions/IMBackAction.cs index ebe6cd546..31ed445a9 100644 --- a/Libraries/Microsoft.Teams.Cards/Actions/IMBackAction.cs +++ b/Libraries/Microsoft.Teams.Cards/Actions/IMBackAction.cs @@ -5,6 +5,10 @@ namespace Microsoft.Teams.Cards; +/// +/// This class is deprecated. Please use instead. This will be removed in a future version of the SDK. +/// +[Obsolete("This class is deprecated. Use ImBackSubmitActionData instead. This will be removed in a future version of the SDK.")] public class IMBackAction : SubmitAction { /// diff --git a/Libraries/Microsoft.Teams.Cards/Actions/InvokeAction.cs b/Libraries/Microsoft.Teams.Cards/Actions/InvokeAction.cs index c416cf558..9009b8d2d 100644 --- a/Libraries/Microsoft.Teams.Cards/Actions/InvokeAction.cs +++ b/Libraries/Microsoft.Teams.Cards/Actions/InvokeAction.cs @@ -6,8 +6,9 @@ namespace Microsoft.Teams.Cards; /// -/// Defines an invoke action. This action is used to trigger a bot action. +/// This class is deprecated. Please use instead. This will be removed in a future version of the SDK. /// +[Obsolete("This class is deprecated. Use InvokeSubmitActionData instead. This will be removed in a future version of the SDK.")] public class InvokeAction : SubmitAction { /// @@ -18,7 +19,7 @@ public InvokeAction(object value) { Data = new Union(new SubmitActionData { - Msteams = new InvokeSubmitActionData(value) + Msteams = new InvokeSubmitActionData(new Union(value)) }); } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Cards/Actions/MessageBackAction.cs b/Libraries/Microsoft.Teams.Cards/Actions/MessageBackAction.cs index 4628f1209..207b08893 100644 --- a/Libraries/Microsoft.Teams.Cards/Actions/MessageBackAction.cs +++ b/Libraries/Microsoft.Teams.Cards/Actions/MessageBackAction.cs @@ -5,6 +5,10 @@ namespace Microsoft.Teams.Cards; +/// +/// This class is deprecated. Please use instead. This will be removed in a future version of the SDK. +/// +[Obsolete("This class is deprecated. Use MessageBackSubmitActionData instead. This will be removed in a future version of the SDK.")] public class MessageBackAction : SubmitAction { public MessageBackAction(string text, string value) diff --git a/Libraries/Microsoft.Teams.Cards/Actions/SignInAction.cs b/Libraries/Microsoft.Teams.Cards/Actions/SignInAction.cs index bf5cd4ec1..bd6145238 100644 --- a/Libraries/Microsoft.Teams.Cards/Actions/SignInAction.cs +++ b/Libraries/Microsoft.Teams.Cards/Actions/SignInAction.cs @@ -5,6 +5,10 @@ namespace Microsoft.Teams.Cards; +/// +/// This class is deprecated. Please use instead. This will be removed in a future version of the SDK. +/// +[Obsolete("This class is deprecated. Use SigninSubmitActionData instead. This will be removed in a future version of the SDK.")] public class SignInAction : SubmitAction { public SignInAction(string value) diff --git a/Libraries/Microsoft.Teams.Cards/Actions/TaskFetchAction.cs b/Libraries/Microsoft.Teams.Cards/Actions/TaskFetchAction.cs index 8b4bf71b1..a55e12623 100644 --- a/Libraries/Microsoft.Teams.Cards/Actions/TaskFetchAction.cs +++ b/Libraries/Microsoft.Teams.Cards/Actions/TaskFetchAction.cs @@ -5,6 +5,10 @@ namespace Microsoft.Teams.Cards; +/// +/// This class is deprecated. Please use instead. This will be removed in a future version of the SDK. +/// +[Obsolete("This class is deprecated. Use TaskFetchSubmitActionData instead. This will be removed in a future version of the SDK.")] public class TaskFetchAction : SubmitAction { public TaskFetchAction(IDictionary? value = null) diff --git a/Libraries/Microsoft.Teams.Cards/Core.cs b/Libraries/Microsoft.Teams.Cards/Core.cs index 5605bb3fc..6f9685805 100644 --- a/Libraries/Microsoft.Teams.Cards/Core.cs +++ b/Libraries/Microsoft.Teams.Cards/Core.cs @@ -1,4 +1,4 @@ -// This file was automatically generated by a tool on 09/16/2025, 10:37 PM UTC. DO NOT UPDATE MANUALLY. +// This file was automatically generated by a tool on 04/06/2026, 11:50 PM UTC. DO NOT UPDATE MANUALLY. // It includes declarations for Adaptive Card features available in Teams, Copilot, Outlook, Word, Excel, PowerPoint. #pragma warning disable IDE0290 @@ -33,56 +33,65 @@ public class ActionMode(string value) : StringEnum(value, caseSensitive: false) public bool IsSecondary => Secondary.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class AssociatedInputs(string value) : StringEnum(value, caseSensitive: false) +[JsonConverter(typeof(JsonConverter))] +public class ThemeName(string value) : StringEnum(value, caseSensitive: false) { - public static readonly AssociatedInputs Auto = new("auto"); - public bool IsAuto => Auto.Equals(Value); + public static readonly ThemeName Light = new("Light"); + public bool IsLight => Light.Equals(Value); - public static readonly AssociatedInputs None = new("none"); - public bool IsNone => None.Equals(Value); + public static readonly ThemeName Dark = new("Dark"); + public bool IsDark => Dark.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class ImageInsertPosition(string value) : StringEnum(value, caseSensitive: false) +[JsonConverter(typeof(JsonConverter))] +public class ElementHeight(string value) : StringEnum(value, caseSensitive: false) { - public static readonly ImageInsertPosition Selection = new("Selection"); - public bool IsSelection => Selection.Equals(Value); - - public static readonly ImageInsertPosition Top = new("Top"); - public bool IsTop => Top.Equals(Value); + public static readonly ElementHeight Auto = new("auto"); + public bool IsAuto => Auto.Equals(Value); - public static readonly ImageInsertPosition Bottom = new("Bottom"); - public bool IsBottom => Bottom.Equals(Value); + public static readonly ElementHeight Stretch = new("stretch"); + public bool IsStretch => Stretch.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class FallbackAction(string value) : StringEnum(value, caseSensitive: false) +[JsonConverter(typeof(JsonConverter))] +public class HorizontalAlignment(string value) : StringEnum(value, caseSensitive: false) { - public static readonly FallbackAction Drop = new("drop"); - public bool IsDrop => Drop.Equals(Value); + public static readonly HorizontalAlignment Left = new("Left"); + public bool IsLeft => Left.Equals(Value); + + public static readonly HorizontalAlignment Center = new("Center"); + public bool IsCenter => Center.Equals(Value); + + public static readonly HorizontalAlignment Right = new("Right"); + public bool IsRight => Right.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class ContainerStyle(string value) : StringEnum(value, caseSensitive: false) +[JsonConverter(typeof(JsonConverter))] +public class Spacing(string value) : StringEnum(value, caseSensitive: false) { - public static readonly ContainerStyle Default = new("default"); - public bool IsDefault => Default.Equals(Value); + public static readonly Spacing None = new("None"); + public bool IsNone => None.Equals(Value); - public static readonly ContainerStyle Emphasis = new("emphasis"); - public bool IsEmphasis => Emphasis.Equals(Value); + public static readonly Spacing ExtraSmall = new("ExtraSmall"); + public bool IsExtraSmall => ExtraSmall.Equals(Value); - public static readonly ContainerStyle Accent = new("accent"); - public bool IsAccent => Accent.Equals(Value); + public static readonly Spacing Small = new("Small"); + public bool IsSmall => Small.Equals(Value); - public static readonly ContainerStyle Good = new("good"); - public bool IsGood => Good.Equals(Value); + public static readonly Spacing Default = new("Default"); + public bool IsDefault => Default.Equals(Value); - public static readonly ContainerStyle Attention = new("attention"); - public bool IsAttention => Attention.Equals(Value); + public static readonly Spacing Medium = new("Medium"); + public bool IsMedium => Medium.Equals(Value); - public static readonly ContainerStyle Warning = new("warning"); - public bool IsWarning => Warning.Equals(Value); + public static readonly Spacing Large = new("Large"); + public bool IsLarge => Large.Equals(Value); + + public static readonly Spacing ExtraLarge = new("ExtraLarge"); + public bool IsExtraLarge => ExtraLarge.Equals(Value); + + public static readonly Spacing Padding = new("Padding"); + public bool IsPadding => Padding.Equals(Value); } [JsonConverter(typeof(JsonConverter))] @@ -125,17 +134,26 @@ public class TargetWidth(string value) : StringEnum(value, caseSensitive: false) public bool IsAtMostWide => AtMostWide.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class HorizontalAlignment(string value) : StringEnum(value, caseSensitive: false) +[JsonConverter(typeof(JsonConverter))] +public class ContainerStyle(string value) : StringEnum(value, caseSensitive: false) { - public static readonly HorizontalAlignment Left = new("Left"); - public bool IsLeft => Left.Equals(Value); + public static readonly ContainerStyle Default = new("default"); + public bool IsDefault => Default.Equals(Value); - public static readonly HorizontalAlignment Center = new("Center"); - public bool IsCenter => Center.Equals(Value); + public static readonly ContainerStyle Emphasis = new("emphasis"); + public bool IsEmphasis => Emphasis.Equals(Value); - public static readonly HorizontalAlignment Right = new("Right"); - public bool IsRight => Right.Equals(Value); + public static readonly ContainerStyle Accent = new("accent"); + public bool IsAccent => Accent.Equals(Value); + + public static readonly ContainerStyle Good = new("good"); + public bool IsGood => Good.Equals(Value); + + public static readonly ContainerStyle Attention = new("attention"); + public bool IsAttention => Attention.Equals(Value); + + public static readonly ContainerStyle Warning = new("warning"); + public bool IsWarning => Warning.Equals(Value); } [JsonConverter(typeof(JsonConverter))] @@ -161,34 +179,6 @@ public class FlowLayoutItemFit(string value) : StringEnum(value, caseSensitive: public bool IsFill => Fill.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class Spacing(string value) : StringEnum(value, caseSensitive: false) -{ - public static readonly Spacing None = new("None"); - public bool IsNone => None.Equals(Value); - - public static readonly Spacing ExtraSmall = new("ExtraSmall"); - public bool IsExtraSmall => ExtraSmall.Equals(Value); - - public static readonly Spacing Small = new("Small"); - public bool IsSmall => Small.Equals(Value); - - public static readonly Spacing Default = new("Default"); - public bool IsDefault => Default.Equals(Value); - - public static readonly Spacing Medium = new("Medium"); - public bool IsMedium => Medium.Equals(Value); - - public static readonly Spacing Large = new("Large"); - public bool IsLarge => Large.Equals(Value); - - public static readonly Spacing ExtraLarge = new("ExtraLarge"); - public bool IsExtraLarge => ExtraLarge.Equals(Value); - - public static readonly Spacing Padding = new("Padding"); - public bool IsPadding => Padding.Equals(Value); -} - [JsonConverter(typeof(JsonConverter))] public class FillMode(string value) : StringEnum(value, caseSensitive: false) { @@ -205,58 +195,6 @@ public class FillMode(string value) : StringEnum(value, caseSensitive: false) public bool IsRepeat => Repeat.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class Version(string value) : StringEnum(value, caseSensitive: false) -{ - public static readonly Version Version1_0 = new("1.0"); - public bool IsVersion1_0 => Version1_0.Equals(Value); - - public static readonly Version Version1_1 = new("1.1"); - public bool IsVersion1_1 => Version1_1.Equals(Value); - - public static readonly Version Version1_2 = new("1.2"); - public bool IsVersion1_2 => Version1_2.Equals(Value); - - public static readonly Version Version1_3 = new("1.3"); - public bool IsVersion1_3 => Version1_3.Equals(Value); - - public static readonly Version Version1_4 = new("1.4"); - public bool IsVersion1_4 => Version1_4.Equals(Value); - - public static readonly Version Version1_5 = new("1.5"); - public bool IsVersion1_5 => Version1_5.Equals(Value); - - public static readonly Version Version1_6 = new("1.6"); - public bool IsVersion1_6 => Version1_6.Equals(Value); -} - -[JsonConverter(typeof(JsonConverter))] -public class TeamsCardWidth(string value) : StringEnum(value, caseSensitive: false) -{ - public static readonly TeamsCardWidth Full = new("full"); - public bool IsFull => Full.Equals(Value); -} - -[JsonConverter(typeof(JsonConverter))] -public class MentionType(string value) : StringEnum(value, caseSensitive: false) -{ - public static readonly MentionType Person = new("Person"); - public bool IsPerson => Person.Equals(Value); - - public static readonly MentionType Tag = new("Tag"); - public bool IsTag => Tag.Equals(Value); -} - -[JsonConverter(typeof(JsonConverter))] -public class ElementHeight(string value) : StringEnum(value, caseSensitive: false) -{ - public static readonly ElementHeight Auto = new("auto"); - public bool IsAuto => Auto.Equals(Value); - - public static readonly ElementHeight Stretch = new("stretch"); - public bool IsStretch => Stretch.Equals(Value); -} - [JsonConverter(typeof(JsonConverter))] public class TextSize(string value) : StringEnum(value, caseSensitive: false) { @@ -324,17 +262,17 @@ public class FontType(string value) : StringEnum(value, caseSensitive: false) public bool IsMonospace => Monospace.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class StyleEnum(string value) : StringEnum(value, caseSensitive: false) +[JsonConverter(typeof(JsonConverter))] +public class TextBlockStyle(string value) : StringEnum(value, caseSensitive: false) { - public static readonly StyleEnum Compact = new("compact"); - public bool IsCompact => Compact.Equals(Value); + public static readonly TextBlockStyle Default = new("default"); + public bool IsDefault => Default.Equals(Value); - public static readonly StyleEnum Expanded = new("expanded"); - public bool IsExpanded => Expanded.Equals(Value); + public static readonly TextBlockStyle ColumnHeader = new("columnHeader"); + public bool IsColumnHeader => ColumnHeader.Equals(Value); - public static readonly StyleEnum Filtered = new("filtered"); - public bool IsFiltered => Filtered.Equals(Value); + public static readonly TextBlockStyle Heading = new("heading"); + public bool IsHeading => Heading.Equals(Value); } [JsonConverter(typeof(JsonConverter))] @@ -369,6 +307,19 @@ public class Size(string value) : StringEnum(value, caseSensitive: false) public bool IsLarge => Large.Equals(Value); } +[JsonConverter(typeof(JsonConverter))] +public class ImageFitMode(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly ImageFitMode Cover = new("Cover"); + public bool IsCover => Cover.Equals(Value); + + public static readonly ImageFitMode Contain = new("Contain"); + public bool IsContain => Contain.Equals(Value); + + public static readonly ImageFitMode Fill = new("Fill"); + public bool IsFill => Fill.Equals(Value); +} + [JsonConverter(typeof(JsonConverter))] public class InputTextStyle(string value) : StringEnum(value, caseSensitive: false) { @@ -388,6 +339,29 @@ public class InputTextStyle(string value) : StringEnum(value, caseSensitive: fal public bool IsPassword => Password.Equals(Value); } +[JsonConverter(typeof(JsonConverter))] +public class AssociatedInputs(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly AssociatedInputs Auto = new("auto"); + public bool IsAuto => Auto.Equals(Value); + + public static readonly AssociatedInputs None = new("none"); + public bool IsNone => None.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class ChoiceSetInputStyle(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly ChoiceSetInputStyle Compact = new("compact"); + public bool IsCompact => Compact.Equals(Value); + + public static readonly ChoiceSetInputStyle Expanded = new("expanded"); + public bool IsExpanded => Expanded.Equals(Value); + + public static readonly ChoiceSetInputStyle Filtered = new("filtered"); + public bool IsFiltered => Filtered.Equals(Value); +} + [JsonConverter(typeof(JsonConverter))] public class RatingSize(string value) : StringEnum(value, caseSensitive: false) { @@ -540,6 +514,54 @@ public class BadgeStyle(string value) : StringEnum(value, caseSensitive: false) public bool IsWarning => Warning.Equals(Value); } +[JsonConverter(typeof(JsonConverter))] +public class ProgressRingLabelPosition(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly ProgressRingLabelPosition Before = new("Before"); + public bool IsBefore => Before.Equals(Value); + + public static readonly ProgressRingLabelPosition After = new("After"); + public bool IsAfter => After.Equals(Value); + + public static readonly ProgressRingLabelPosition Above = new("Above"); + public bool IsAbove => Above.Equals(Value); + + public static readonly ProgressRingLabelPosition Below = new("Below"); + public bool IsBelow => Below.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class ProgressRingSize(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly ProgressRingSize Tiny = new("Tiny"); + public bool IsTiny => Tiny.Equals(Value); + + public static readonly ProgressRingSize Small = new("Small"); + public bool IsSmall => Small.Equals(Value); + + public static readonly ProgressRingSize Medium = new("Medium"); + public bool IsMedium => Medium.Equals(Value); + + public static readonly ProgressRingSize Large = new("Large"); + public bool IsLarge => Large.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class ProgressBarColor(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly ProgressBarColor Accent = new("Accent"); + public bool IsAccent => Accent.Equals(Value); + + public static readonly ProgressBarColor Good = new("Good"); + public bool IsGood => Good.Equals(Value); + + public static readonly ProgressBarColor Warning = new("Warning"); + public bool IsWarning => Warning.Equals(Value); + + public static readonly ProgressBarColor Attention = new("Attention"); + public bool IsAttention => Attention.Equals(Value); +} + [JsonConverter(typeof(JsonConverter))] public class ChartColorSet(string value) : StringEnum(value, caseSensitive: false) { @@ -549,6 +571,15 @@ public class ChartColorSet(string value) : StringEnum(value, caseSensitive: fals public static readonly ChartColorSet Sequential = new("sequential"); public bool IsSequential => Sequential.Equals(Value); + public static readonly ChartColorSet Sequentialred = new("sequentialred"); + public bool IsSequentialred => Sequentialred.Equals(Value); + + public static readonly ChartColorSet Sequentialgreen = new("sequentialgreen"); + public bool IsSequentialgreen => Sequentialgreen.Equals(Value); + + public static readonly ChartColorSet Sequentialyellow = new("sequentialyellow"); + public bool IsSequentialyellow => Sequentialyellow.Equals(Value); + public static readonly ChartColorSet Diverging = new("diverging"); public bool IsDiverging => Diverging.Equals(Value); } @@ -648,28 +679,110 @@ public class ChartColor(string value) : StringEnum(value, caseSensitive: false) public static readonly ChartColor DivergingGray = new("divergingGray"); public bool IsDivergingGray => DivergingGray.Equals(Value); -} -[JsonConverter(typeof(JsonConverter))] -public class HorizontalBarChartDisplayMode(string value) : StringEnum(value, caseSensitive: false) -{ - public static readonly HorizontalBarChartDisplayMode AbsoluteWithAxis = new("AbsoluteWithAxis"); - public bool IsAbsoluteWithAxis => AbsoluteWithAxis.Equals(Value); + public static readonly ChartColor SequentialRed1 = new("sequentialRed1"); + public bool IsSequentialRed1 => SequentialRed1.Equals(Value); - public static readonly HorizontalBarChartDisplayMode AbsoluteNoAxis = new("AbsoluteNoAxis"); - public bool IsAbsoluteNoAxis => AbsoluteNoAxis.Equals(Value); + public static readonly ChartColor SequentialRed2 = new("sequentialRed2"); + public bool IsSequentialRed2 => SequentialRed2.Equals(Value); - public static readonly HorizontalBarChartDisplayMode PartToWhole = new("PartToWhole"); - public bool IsPartToWhole => PartToWhole.Equals(Value); -} + public static readonly ChartColor SequentialRed3 = new("sequentialRed3"); + public bool IsSequentialRed3 => SequentialRed3.Equals(Value); -[JsonConverter(typeof(JsonConverter))] -public class GaugeChartValueFormat(string value) : StringEnum(value, caseSensitive: false) -{ - public static readonly GaugeChartValueFormat Percentage = new("Percentage"); - public bool IsPercentage => Percentage.Equals(Value); + public static readonly ChartColor SequentialRed4 = new("sequentialRed4"); + public bool IsSequentialRed4 => SequentialRed4.Equals(Value); - public static readonly GaugeChartValueFormat Fraction = new("Fraction"); + public static readonly ChartColor SequentialRed5 = new("sequentialRed5"); + public bool IsSequentialRed5 => SequentialRed5.Equals(Value); + + public static readonly ChartColor SequentialRed6 = new("sequentialRed6"); + public bool IsSequentialRed6 => SequentialRed6.Equals(Value); + + public static readonly ChartColor SequentialRed7 = new("sequentialRed7"); + public bool IsSequentialRed7 => SequentialRed7.Equals(Value); + + public static readonly ChartColor SequentialRed8 = new("sequentialRed8"); + public bool IsSequentialRed8 => SequentialRed8.Equals(Value); + + public static readonly ChartColor SequentialGreen1 = new("sequentialGreen1"); + public bool IsSequentialGreen1 => SequentialGreen1.Equals(Value); + + public static readonly ChartColor SequentialGreen2 = new("sequentialGreen2"); + public bool IsSequentialGreen2 => SequentialGreen2.Equals(Value); + + public static readonly ChartColor SequentialGreen3 = new("sequentialGreen3"); + public bool IsSequentialGreen3 => SequentialGreen3.Equals(Value); + + public static readonly ChartColor SequentialGreen4 = new("sequentialGreen4"); + public bool IsSequentialGreen4 => SequentialGreen4.Equals(Value); + + public static readonly ChartColor SequentialGreen5 = new("sequentialGreen5"); + public bool IsSequentialGreen5 => SequentialGreen5.Equals(Value); + + public static readonly ChartColor SequentialGreen6 = new("sequentialGreen6"); + public bool IsSequentialGreen6 => SequentialGreen6.Equals(Value); + + public static readonly ChartColor SequentialGreen7 = new("sequentialGreen7"); + public bool IsSequentialGreen7 => SequentialGreen7.Equals(Value); + + public static readonly ChartColor SequentialGreen8 = new("sequentialGreen8"); + public bool IsSequentialGreen8 => SequentialGreen8.Equals(Value); + + public static readonly ChartColor SequentialYellow1 = new("sequentialYellow1"); + public bool IsSequentialYellow1 => SequentialYellow1.Equals(Value); + + public static readonly ChartColor SequentialYellow2 = new("sequentialYellow2"); + public bool IsSequentialYellow2 => SequentialYellow2.Equals(Value); + + public static readonly ChartColor SequentialYellow3 = new("sequentialYellow3"); + public bool IsSequentialYellow3 => SequentialYellow3.Equals(Value); + + public static readonly ChartColor SequentialYellow4 = new("sequentialYellow4"); + public bool IsSequentialYellow4 => SequentialYellow4.Equals(Value); + + public static readonly ChartColor SequentialYellow5 = new("sequentialYellow5"); + public bool IsSequentialYellow5 => SequentialYellow5.Equals(Value); + + public static readonly ChartColor SequentialYellow6 = new("sequentialYellow6"); + public bool IsSequentialYellow6 => SequentialYellow6.Equals(Value); + + public static readonly ChartColor SequentialYellow7 = new("sequentialYellow7"); + public bool IsSequentialYellow7 => SequentialYellow7.Equals(Value); + + public static readonly ChartColor SequentialYellow8 = new("sequentialYellow8"); + public bool IsSequentialYellow8 => SequentialYellow8.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class DonutThickness(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly DonutThickness Thin = new("Thin"); + public bool IsThin => Thin.Equals(Value); + + public static readonly DonutThickness Thick = new("Thick"); + public bool IsThick => Thick.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class HorizontalBarChartDisplayMode(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly HorizontalBarChartDisplayMode AbsoluteWithAxis = new("AbsoluteWithAxis"); + public bool IsAbsoluteWithAxis => AbsoluteWithAxis.Equals(Value); + + public static readonly HorizontalBarChartDisplayMode AbsoluteNoAxis = new("AbsoluteNoAxis"); + public bool IsAbsoluteNoAxis => AbsoluteNoAxis.Equals(Value); + + public static readonly HorizontalBarChartDisplayMode PartToWhole = new("PartToWhole"); + public bool IsPartToWhole => PartToWhole.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class GaugeChartValueFormat(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly GaugeChartValueFormat Percentage = new("Percentage"); + public bool IsPercentage => Percentage.Equals(Value); + + public static readonly GaugeChartValueFormat Fraction = new("Fraction"); public bool IsFraction => Fraction.Equals(Value); } @@ -749,6 +862,32 @@ public class CodeLanguage(string value) : StringEnum(value, caseSensitive: false public bool IsXml => Xml.Equals(Value); } +[JsonConverter(typeof(JsonConverter))] +public class PersonaIconStyle(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly PersonaIconStyle ProfilePicture = new("profilePicture"); + public bool IsProfilePicture => ProfilePicture.Equals(Value); + + public static readonly PersonaIconStyle ContactCard = new("contactCard"); + public bool IsContactCard => ContactCard.Equals(Value); + + public static readonly PersonaIconStyle None = new("none"); + public bool IsNone => None.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class PersonaDisplayStyle(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly PersonaDisplayStyle IconAndName = new("iconAndName"); + public bool IsIconAndName => IconAndName.Equals(Value); + + public static readonly PersonaDisplayStyle IconOnly = new("iconOnly"); + public bool IsIconOnly => IconOnly.Equals(Value); + + public static readonly PersonaDisplayStyle NameOnly = new("nameOnly"); + public bool IsNameOnly => NameOnly.Equals(Value); +} + [JsonConverter(typeof(JsonConverter))] public class FallbackElement(string value) : StringEnum(value, caseSensitive: false) { @@ -788,14 +927,82 @@ public class SizeEnum(string value) : StringEnum(value, caseSensitive: false) public bool IsExtraLarge => ExtraLarge.Equals(Value); } -[JsonConverter(typeof(JsonConverter))] -public class ThemeName(string value) : StringEnum(value, caseSensitive: false) +[JsonConverter(typeof(JsonConverter))] +public class PopoverPosition(string value) : StringEnum(value, caseSensitive: false) { - public static readonly ThemeName Light = new("Light"); - public bool IsLight => Light.Equals(Value); + public static readonly PopoverPosition Above = new("Above"); + public bool IsAbove => Above.Equals(Value); - public static readonly ThemeName Dark = new("Dark"); - public bool IsDark => Dark.Equals(Value); + public static readonly PopoverPosition Below = new("Below"); + public bool IsBelow => Below.Equals(Value); + + public static readonly PopoverPosition Before = new("Before"); + public bool IsBefore => Before.Equals(Value); + + public static readonly PopoverPosition After = new("After"); + public bool IsAfter => After.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class FallbackAction(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly FallbackAction Drop = new("drop"); + public bool IsDrop => Drop.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class ImageInsertPosition(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly ImageInsertPosition Selection = new("Selection"); + public bool IsSelection => Selection.Equals(Value); + + public static readonly ImageInsertPosition Top = new("Top"); + public bool IsTop => Top.Equals(Value); + + public static readonly ImageInsertPosition Bottom = new("Bottom"); + public bool IsBottom => Bottom.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class Version(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly Version Version1_0 = new("1.0"); + public bool IsVersion1_0 => Version1_0.Equals(Value); + + public static readonly Version Version1_1 = new("1.1"); + public bool IsVersion1_1 => Version1_1.Equals(Value); + + public static readonly Version Version1_2 = new("1.2"); + public bool IsVersion1_2 => Version1_2.Equals(Value); + + public static readonly Version Version1_3 = new("1.3"); + public bool IsVersion1_3 => Version1_3.Equals(Value); + + public static readonly Version Version1_4 = new("1.4"); + public bool IsVersion1_4 => Version1_4.Equals(Value); + + public static readonly Version Version1_5 = new("1.5"); + public bool IsVersion1_5 => Version1_5.Equals(Value); + + public static readonly Version Version1_6 = new("1.6"); + public bool IsVersion1_6 => Version1_6.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class TeamsCardWidth(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly TeamsCardWidth Full = new("full"); + public bool IsFull => Full.Equals(Value); +} + +[JsonConverter(typeof(JsonConverter))] +public class MentionType(string value) : StringEnum(value, caseSensitive: false) +{ + public static readonly MentionType Person = new("Person"); + public bool IsPerson => Person.Equals(Value); + + public static readonly MentionType Tag = new("Tag"); + public bool IsTag => Tag.Equals(Value); } internal record ObjectType(string[] DiscriminatorPropertyNames, string DiscriminatorValue, Type Type) { } @@ -807,8 +1014,8 @@ internal sealed class CardElementJsonConverter : JsonConverter private static readonly List _typeMap = new() { new ObjectType(["type"], "AdaptiveCard", typeof(AdaptiveCard)), - new ObjectType(["type"], "Container", typeof(Container)), new ObjectType(["type"], "ActionSet", typeof(ActionSet)), + new ObjectType(["type"], "Container", typeof(Container)), new ObjectType(["type"], "ColumnSet", typeof(ColumnSet)), new ObjectType(["type"], "Media", typeof(Media)), new ObjectType(["type"], "RichTextBlock", typeof(RichTextBlock)), @@ -829,6 +1036,8 @@ internal sealed class CardElementJsonConverter : JsonConverter new ObjectType(["type"], "Icon", typeof(Icon)), new ObjectType(["type"], "Carousel", typeof(Carousel)), new ObjectType(["type"], "Badge", typeof(Badge)), + new ObjectType(["type"], "ProgressRing", typeof(ProgressRing)), + new ObjectType(["type"], "ProgressBar", typeof(ProgressBar)), new ObjectType(["type"], "Chart.Donut", typeof(DonutChart)), new ObjectType(["type"], "Chart.Pie", typeof(PieChart)), new ObjectType(["type"], "Chart.VerticalBar.Grouped", typeof(GroupedVerticalBarChart)), @@ -894,12 +1103,14 @@ internal sealed class ActionJsonConverter : JsonConverter private static readonly List _typeMap = new() { new ObjectType(["type"], "Action.Execute", typeof(ExecuteAction)), - new ObjectType(["type"], "Action.Submit", typeof(SubmitAction)), + new ObjectType(["type"], "Action.InsertImage", typeof(InsertImageAction)), new ObjectType(["type"], "Action.OpenUrl", typeof(OpenUrlAction)), + new ObjectType(["type"], "Action.OpenUrlDialog", typeof(OpenUrlDialogAction)), + new ObjectType(["type"], "Action.ResetInputs", typeof(ResetInputsAction)), + new ObjectType(["type"], "Action.Submit", typeof(SubmitAction)), new ObjectType(["type"], "Action.ToggleVisibility", typeof(ToggleVisibilityAction)), new ObjectType(["type"], "Action.ShowCard", typeof(ShowCardAction)), - new ObjectType(["type"], "Action.ResetInputs", typeof(ResetInputsAction)), - new ObjectType(["type"], "Action.InsertImage", typeof(InsertImageAction)), + new ObjectType(["type"], "Action.Popover", typeof(PopoverAction)), }; public override Action? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -998,6 +1209,12 @@ public class AdaptiveCard : CardElement return JsonSerializer.Deserialize(json); } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + /// /// Must be **AdaptiveCard**. /// @@ -1014,7 +1231,7 @@ public class AdaptiveCard : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -1026,7 +1243,7 @@ public class AdaptiveCard : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. @@ -1080,7 +1297,7 @@ public class AdaptiveCard : CardElement /// The Adaptive Card schema version the card is authored against. /// [JsonPropertyName("version")] - public Version? Version { get; set; } + public Version? Version { get; set; } = Version.Version1_5; /// /// The text that should be displayed if the client is not able to render the card. @@ -1106,6 +1323,9 @@ public class AdaptiveCard : CardElement [JsonPropertyName("authentication")] public Authentication? Authentication { get; set; } + /// + /// Teams-specific metadata associated with the card. + /// [JsonPropertyName("msteams")] public TeamsCardProperties? Msteams { get; set; } @@ -1115,6 +1335,12 @@ public class AdaptiveCard : CardElement [JsonPropertyName("metadata")] public CardMetadata? Metadata { get; set; } + /// + /// Resources card elements can reference. + /// + [JsonPropertyName("resources")] + public Resources? Resources { get; set; } + /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. /// @@ -1139,7 +1365,14 @@ public class AdaptiveCard : CardElement [JsonPropertyName("actions")] public IList? Actions { get; set; } - public AdaptiveCard(params IList body) + public AdaptiveCard() { } + + public AdaptiveCard(params CardElement[] body) + { + this.Body = new List(body); + } + + public AdaptiveCard(IList body) { this.Body = body; } @@ -1159,6 +1392,12 @@ public string Serialize() ); } + public AdaptiveCard WithKey(string value) + { + this.Key = value; + return this; + } + public AdaptiveCard WithId(string value) { this.Id = value; @@ -1195,7 +1434,13 @@ public AdaptiveCard WithStyle(ContainerStyle value) return this; } - public AdaptiveCard WithLayouts(params IList value) + public AdaptiveCard WithLayouts(params ContainerLayout[] value) + { + this.Layouts = new List(value); + return this; + } + + public AdaptiveCard WithLayouts(IList value) { this.Layouts = value; return this; @@ -1273,6 +1518,12 @@ public AdaptiveCard WithMetadata(CardMetadata value) return this; } + public AdaptiveCard WithResources(Resources value) + { + this.Resources = value; + return this; + } + public AdaptiveCard WithGridArea(string value) { this.GridArea = value; @@ -1285,13 +1536,25 @@ public AdaptiveCard WithFallback(IUnion value) return this; } - public AdaptiveCard WithBody(params IList value) + public AdaptiveCard WithBody(params CardElement[] value) + { + this.Body = new List(value); + return this; + } + + public AdaptiveCard WithBody(IList value) { this.Body = value; return this; } - public AdaptiveCard WithActions(params IList value) + public AdaptiveCard WithActions(params Action[] value) + { + this.Actions = new List(value); + return this; + } + + public AdaptiveCard WithActions(IList value) { this.Actions = value; return this; @@ -1311,6 +1574,11 @@ public class HostCapabilities : SerializableObject return JsonSerializer.Deserialize(json); } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } /// /// Serializes this HostCapabilities into a JSON string. @@ -1326,6 +1594,12 @@ public string Serialize() } ); } + + public HostCapabilities WithKey(string value) + { + this.Key = value; + return this; + } [JsonExtensionData] public IDictionary NonSchemaProperties { get; set; } = new Dictionary(); } @@ -1343,6 +1617,12 @@ public class ExecuteAction : Action return JsonSerializer.Deserialize(json); } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + /// /// Must be **Action.Execute**. /// @@ -1359,7 +1639,7 @@ public class ExecuteAction : Action /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The title of the action, as it appears on buttons. @@ -1379,13 +1659,13 @@ public class ExecuteAction : Action /// Control the style of the action, affecting its visual and spoken representations. /// [JsonPropertyName("style")] - public ActionStyle? Style { get; set; } + public ActionStyle? Style { get; set; } = ActionStyle.Default; /// /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. /// [JsonPropertyName("mode")] - public ActionMode? Mode { get; set; } + public ActionMode? Mode { get; set; } = ActionMode.Primary; /// /// The tooltip text to display when the action is hovered over. @@ -1397,7 +1677,19 @@ public class ExecuteAction : Action /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. /// [JsonPropertyName("isEnabled")] - public bool? IsEnabled { get; set; } + public bool? IsEnabled { get; set; } = true; + + /// + /// The actions to display in the overflow menu of a Split action button. + /// + [JsonPropertyName("menuActions")] + public IList? MenuActions { get; set; } + + /// + /// A set of theme-specific icon URLs. + /// + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } /// /// The data to send to the Bot when the action is executed. When expressed as an object, `data` is sent back to the Bot when the action is executed, adorned with the values of the inputs expressed as key/value pairs, where the key is the Id of the input. If `data` is expressed as a string, input values are not sent to the Bot. @@ -1415,7 +1707,7 @@ public class ExecuteAction : Action /// Controls if the action is enabled only if at least one required input has been filled by the user. /// [JsonPropertyName("conditionallyEnabled")] - public bool? ConditionallyEnabled { get; set; } + public bool? ConditionallyEnabled { get; set; } = false; /// /// The verb of the action. @@ -1444,6 +1736,12 @@ public string Serialize() ); } + public ExecuteAction WithKey(string value) + { + this.Key = value; + return this; + } + public ExecuteAction WithId(string value) { this.Id = value; @@ -1492,6 +1790,30 @@ public ExecuteAction WithIsEnabled(bool value) return this; } + public ExecuteAction WithMenuActions(params Action[] value) + { + this.MenuActions = new List(value); + return this; + } + + public ExecuteAction WithMenuActions(IList value) + { + this.MenuActions = value; + return this; + } + + public ExecuteAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public ExecuteAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + public ExecuteAction WithData(IUnion value) { this.Data = value; @@ -1524,188 +1846,118 @@ public ExecuteAction WithFallback(IUnion value) } /// -/// Represents the data of an Action.Submit. +/// Inserts an image into the host application's canvas. /// -public class SubmitActionData : SerializableObject +public class InsertImageAction : Action { /// - /// Deserializes a JSON string into an object of type SubmitActionData. + /// Deserializes a JSON string into an object of type InsertImageAction. /// - public static SubmitActionData? Deserialize(string json) + public static InsertImageAction? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } - [JsonPropertyName("msteams")] - public object? Msteams { get; set; } - /// - /// Serializes this SubmitActionData into a JSON string. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } - - public SubmitActionData WithMsteams(object value) - { - this.Msteams = value; - return this; - } - [JsonExtensionData] - public IDictionary NonSchemaProperties { get; set; } = new Dictionary(); -} + [JsonPropertyName("key")] + public string? Key { get; set; } -/// -/// Represents Teams-specific data in an Action.Submit to send an Instant Message back to the Bot. -/// -public class ImBackSubmitActionData : SerializableObject -{ /// - /// Deserializes a JSON string into an object of type ImBackSubmitActionData. + /// Must be **Action.InsertImage**. /// - public static ImBackSubmitActionData? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("type")] + public string Type { get; } = "Action.InsertImage"; /// - /// Must be **imBack**. + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. /// - [JsonPropertyName("type")] - public string Type { get; } = "imBack"; + [JsonPropertyName("id")] + public string? Id { get; set; } /// - /// The value that will be sent to the Bot. + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// - [JsonPropertyName("value")] - public string? Value { get; set; } - - public ImBackSubmitActionData(string value) - { - this.Value = value; - } + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// - /// Serializes this ImBackSubmitActionData into a JSON string. + /// The title of the action, as it appears on buttons. /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } - - public ImBackSubmitActionData WithValue(string value) - { - this.Value = value; - return this; - } -} + [JsonPropertyName("title")] + public string? Title { get; set; } -/// -/// Represents Teams-specific data in an Action.Submit to make an Invoke request to the Bot. -/// -public class InvokeSubmitActionData : SerializableObject -{ /// - /// Deserializes a JSON string into an object of type InvokeSubmitActionData. + /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. + /// + /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. /// - public static InvokeSubmitActionData? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } /// - /// Must be **invoke**. + /// Control the style of the action, affecting its visual and spoken representations. /// - [JsonPropertyName("type")] - public string Type { get; } = "invoke"; + [JsonPropertyName("style")] + public ActionStyle? Style { get; set; } = ActionStyle.Default; /// - /// The object to send to the Bot with the Invoke request. + /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. /// - [JsonPropertyName("value")] - public object? Value { get; set; } + [JsonPropertyName("mode")] + public ActionMode? Mode { get; set; } = ActionMode.Primary; - public InvokeSubmitActionData(object value) - { - this.Value = value; - } + /// + /// The tooltip text to display when the action is hovered over. + /// + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } /// - /// Serializes this InvokeSubmitActionData into a JSON string. + /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } = true; - public InvokeSubmitActionData WithValue(object value) - { - this.Value = value; - return this; - } -} + /// + /// The actions to display in the overflow menu of a Split action button. + /// + [JsonPropertyName("menuActions")] + public IList? MenuActions { get; set; } -/// -/// Represents Teams-specific data in an Action.Submit to send a message back to the Bot. -/// -public class MessageBackSubmitActionData : SerializableObject -{ /// - /// Deserializes a JSON string into an object of type MessageBackSubmitActionData. + /// A set of theme-specific icon URLs. /// - public static MessageBackSubmitActionData? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } /// - /// Must be **messageBack**. + /// The URL of the image to insert. /// - [JsonPropertyName("type")] - public string Type { get; } = "messageBack"; + [JsonPropertyName("url")] + public string? Url { get; set; } /// - /// The text that will be sent to the Bot. + /// The alternate text for the image. /// - [JsonPropertyName("text")] - public string? Text { get; set; } + [JsonPropertyName("altText")] + public string? AltText { get; set; } /// - /// The optional text that will be displayed as a new message in the conversation, as if the end-user sent it. `displayText` is not sent to the Bot. + /// The position at which to insert the image. /// - [JsonPropertyName("displayText")] - public string? DisplayText { get; set; } + [JsonPropertyName("insertPosition")] + public ImageInsertPosition? InsertPosition { get; set; } = ImageInsertPosition.Selection; /// - /// Optional additional value that will be sent to the Bot. For instance, `value` can encode specific context for the action, such as unique identifiers or a JSON object. + /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. /// - [JsonPropertyName("value")] - public string? Value { get; set; } + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } /// - /// Serializes this MessageBackSubmitActionData into a JSON string. + /// Serializes this InsertImageAction into a JSON string. /// public string Serialize() { @@ -1719,210 +1971,2169 @@ public string Serialize() ); } - public MessageBackSubmitActionData WithText(string value) + public InsertImageAction WithKey(string value) { - this.Text = value; + this.Key = value; return this; } - public MessageBackSubmitActionData WithDisplayText(string value) + public InsertImageAction WithId(string value) { - this.DisplayText = value; + this.Id = value; return this; } - public MessageBackSubmitActionData WithValue(string value) + public InsertImageAction WithRequires(HostCapabilities value) { - this.Value = value; + this.Requires = value; return this; } -} -/// -/// Represents Teams-specific data in an Action.Submit to sign in a user. -/// -public class SigninSubmitActionData : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type SigninSubmitActionData. - /// - public static SigninSubmitActionData? Deserialize(string json) + public InsertImageAction WithTitle(string value) { - return JsonSerializer.Deserialize(json); + this.Title = value; + return this; } - /// - /// Must be **signin**. - /// - [JsonPropertyName("type")] - public string Type { get; } = "signin"; + public InsertImageAction WithIconUrl(string value) + { + this.IconUrl = value; + return this; + } - /// - /// The URL to redirect the end-user for signing in. - /// - [JsonPropertyName("value")] - public string? Value { get; set; } + public InsertImageAction WithStyle(ActionStyle value) + { + this.Style = value; + return this; + } - public SigninSubmitActionData(string value) + public InsertImageAction WithMode(ActionMode value) { - this.Value = value; + this.Mode = value; + return this; } - /// - /// Serializes this SigninSubmitActionData into a JSON string. - /// - public string Serialize() + public InsertImageAction WithTooltip(string value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Tooltip = value; + return this; } - public SigninSubmitActionData WithValue(string value) + public InsertImageAction WithIsEnabled(bool value) { - this.Value = value; + this.IsEnabled = value; + return this; + } + + public InsertImageAction WithMenuActions(params Action[] value) + { + this.MenuActions = new List(value); + return this; + } + + public InsertImageAction WithMenuActions(IList value) + { + this.MenuActions = value; + return this; + } + + public InsertImageAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public InsertImageAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + + public InsertImageAction WithUrl(string value) + { + this.Url = value; + return this; + } + + public InsertImageAction WithAltText(string value) + { + this.AltText = value; + return this; + } + + public InsertImageAction WithInsertPosition(ImageInsertPosition value) + { + this.InsertPosition = value; + return this; + } + + public InsertImageAction WithFallback(IUnion value) + { + this.Fallback = value; return this; } } /// -/// Represents Teams-specific data in an Action.Submit to open a task module. +/// Opens the provided URL in either a separate browser tab or within the host application. /// -public class TaskFetchSubmitActionData : SerializableObject +public class OpenUrlAction : Action { /// - /// Deserializes a JSON string into an object of type TaskFetchSubmitActionData. + /// Deserializes a JSON string into an object of type OpenUrlAction. /// - public static TaskFetchSubmitActionData? Deserialize(string json) + public static OpenUrlAction? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **task/fetch**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Action.OpenUrl**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Action.OpenUrl"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The title of the action, as it appears on buttons. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. + /// + /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } + + /// + /// Control the style of the action, affecting its visual and spoken representations. + /// + [JsonPropertyName("style")] + public ActionStyle? Style { get; set; } = ActionStyle.Default; + + /// + /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// + [JsonPropertyName("mode")] + public ActionMode? Mode { get; set; } = ActionMode.Primary; + + /// + /// The tooltip text to display when the action is hovered over. + /// + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } + + /// + /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } = true; + + /// + /// The actions to display in the overflow menu of a Split action button. + /// + [JsonPropertyName("menuActions")] + public IList? MenuActions { get; set; } + + /// + /// A set of theme-specific icon URLs. + /// + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } + + /// + /// The URL to open. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + public OpenUrlAction() { } + + public OpenUrlAction(string url) + { + this.Url = url; + } + + /// + /// Serializes this OpenUrlAction into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public OpenUrlAction WithKey(string value) + { + this.Key = value; + return this; + } + + public OpenUrlAction WithId(string value) + { + this.Id = value; + return this; + } + + public OpenUrlAction WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public OpenUrlAction WithTitle(string value) + { + this.Title = value; + return this; + } + + public OpenUrlAction WithIconUrl(string value) + { + this.IconUrl = value; + return this; + } + + public OpenUrlAction WithStyle(ActionStyle value) + { + this.Style = value; + return this; + } + + public OpenUrlAction WithMode(ActionMode value) + { + this.Mode = value; + return this; + } + + public OpenUrlAction WithTooltip(string value) + { + this.Tooltip = value; + return this; + } + + public OpenUrlAction WithIsEnabled(bool value) + { + this.IsEnabled = value; + return this; + } + + public OpenUrlAction WithMenuActions(params Action[] value) + { + this.MenuActions = new List(value); + return this; + } + + public OpenUrlAction WithMenuActions(IList value) + { + this.MenuActions = value; + return this; + } + + public OpenUrlAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public OpenUrlAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + + public OpenUrlAction WithUrl(string value) + { + this.Url = value; + return this; + } + + public OpenUrlAction WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} + +/// +/// Opens a task module in a modal dialog hosting the content at a provided URL. +/// +public class OpenUrlDialogAction : Action +{ + /// + /// Deserializes a JSON string into an object of type OpenUrlDialogAction. + /// + public static OpenUrlDialogAction? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Action.OpenUrlDialog**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Action.OpenUrlDialog"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The title of the action, as it appears on buttons. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. + /// + /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } + + /// + /// Control the style of the action, affecting its visual and spoken representations. + /// + [JsonPropertyName("style")] + public ActionStyle? Style { get; set; } = ActionStyle.Default; + + /// + /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// + [JsonPropertyName("mode")] + public ActionMode? Mode { get; set; } = ActionMode.Primary; + + /// + /// The tooltip text to display when the action is hovered over. + /// + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } + + /// + /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } = true; + + /// + /// The actions to display in the overflow menu of a Split action button. + /// + [JsonPropertyName("menuActions")] + public IList? MenuActions { get; set; } + + /// + /// A set of theme-specific icon URLs. + /// + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } + + /// + /// The title of the dialog to be displayed in the dialog header. + /// + [JsonPropertyName("dialogTitle")] + public string? DialogTitle { get; set; } + + /// + /// The height of the dialog. To define height as a number of pixels, use the px format. + /// + [JsonPropertyName("dialogHeight")] + public string? DialogHeight { get; set; } + + /// + /// The width of the dialog. To define width as a number of pixels, use the px format. + /// + [JsonPropertyName("dialogWidth")] + public string? DialogWidth { get; set; } + + /// + /// The URL to open. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + /// + /// Serializes this OpenUrlDialogAction into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public OpenUrlDialogAction WithKey(string value) + { + this.Key = value; + return this; + } + + public OpenUrlDialogAction WithId(string value) + { + this.Id = value; + return this; + } + + public OpenUrlDialogAction WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public OpenUrlDialogAction WithTitle(string value) + { + this.Title = value; + return this; + } + + public OpenUrlDialogAction WithIconUrl(string value) + { + this.IconUrl = value; + return this; + } + + public OpenUrlDialogAction WithStyle(ActionStyle value) + { + this.Style = value; + return this; + } + + public OpenUrlDialogAction WithMode(ActionMode value) + { + this.Mode = value; + return this; + } + + public OpenUrlDialogAction WithTooltip(string value) + { + this.Tooltip = value; + return this; + } + + public OpenUrlDialogAction WithIsEnabled(bool value) + { + this.IsEnabled = value; + return this; + } + + public OpenUrlDialogAction WithMenuActions(params Action[] value) + { + this.MenuActions = new List(value); + return this; + } + + public OpenUrlDialogAction WithMenuActions(IList value) + { + this.MenuActions = value; + return this; + } + + public OpenUrlDialogAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public OpenUrlDialogAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + + public OpenUrlDialogAction WithDialogTitle(string value) + { + this.DialogTitle = value; + return this; + } + + public OpenUrlDialogAction WithDialogHeight(string value) + { + this.DialogHeight = value; + return this; + } + + public OpenUrlDialogAction WithDialogWidth(string value) + { + this.DialogWidth = value; + return this; + } + + public OpenUrlDialogAction WithUrl(string value) + { + this.Url = value; + return this; + } + + public OpenUrlDialogAction WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} + +/// +/// Resets the values of the inputs in the card. +/// +public class ResetInputsAction : Action +{ + /// + /// Deserializes a JSON string into an object of type ResetInputsAction. + /// + public static ResetInputsAction? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Action.ResetInputs**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Action.ResetInputs"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The title of the action, as it appears on buttons. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. + /// + /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } + + /// + /// Control the style of the action, affecting its visual and spoken representations. + /// + [JsonPropertyName("style")] + public ActionStyle? Style { get; set; } = ActionStyle.Default; + + /// + /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// + [JsonPropertyName("mode")] + public ActionMode? Mode { get; set; } = ActionMode.Primary; + + /// + /// The tooltip text to display when the action is hovered over. + /// + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } + + /// + /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } = true; + + /// + /// The actions to display in the overflow menu of a Split action button. + /// + [JsonPropertyName("menuActions")] + public IList? MenuActions { get; set; } + + /// + /// A set of theme-specific icon URLs. + /// + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } + + /// + /// The Ids of the inputs that should be reset. + /// + [JsonPropertyName("targetInputIds")] + public IList? TargetInputIds { get; set; } + + /// + /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + /// + /// Serializes this ResetInputsAction into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public ResetInputsAction WithKey(string value) + { + this.Key = value; + return this; + } + + public ResetInputsAction WithId(string value) + { + this.Id = value; + return this; + } + + public ResetInputsAction WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public ResetInputsAction WithTitle(string value) + { + this.Title = value; + return this; + } + + public ResetInputsAction WithIconUrl(string value) + { + this.IconUrl = value; + return this; + } + + public ResetInputsAction WithStyle(ActionStyle value) + { + this.Style = value; + return this; + } + + public ResetInputsAction WithMode(ActionMode value) + { + this.Mode = value; + return this; + } + + public ResetInputsAction WithTooltip(string value) + { + this.Tooltip = value; + return this; + } + + public ResetInputsAction WithIsEnabled(bool value) + { + this.IsEnabled = value; + return this; + } + + public ResetInputsAction WithMenuActions(params Action[] value) + { + this.MenuActions = new List(value); + return this; + } + + public ResetInputsAction WithMenuActions(IList value) + { + this.MenuActions = value; + return this; + } + + public ResetInputsAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public ResetInputsAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + + public ResetInputsAction WithTargetInputIds(params string[] value) + { + this.TargetInputIds = new List(value); + return this; + } + + public ResetInputsAction WithTargetInputIds(IList value) + { + this.TargetInputIds = value; + return this; + } + + public ResetInputsAction WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} + +/// +/// Gathers input values, merges them with the data property if specified, and sends them to the Bot via an Invoke activity. The Bot can only acknowledge is has received the request. +/// +public class SubmitAction : Action +{ + /// + /// Deserializes a JSON string into an object of type SubmitAction. + /// + public static SubmitAction? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Action.Submit**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Action.Submit"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The title of the action, as it appears on buttons. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. + /// + /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } + + /// + /// Control the style of the action, affecting its visual and spoken representations. + /// + [JsonPropertyName("style")] + public ActionStyle? Style { get; set; } = ActionStyle.Default; + + /// + /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// + [JsonPropertyName("mode")] + public ActionMode? Mode { get; set; } = ActionMode.Primary; + + /// + /// The tooltip text to display when the action is hovered over. + /// + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } + + /// + /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } = true; + + /// + /// The actions to display in the overflow menu of a Split action button. + /// + [JsonPropertyName("menuActions")] + public IList? MenuActions { get; set; } + + /// + /// A set of theme-specific icon URLs. + /// + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } + + /// + /// The data to send to the Bot when the action is executed. When expressed as an object, `data` is sent back to the Bot when the action is executed, adorned with the values of the inputs expressed as key/value pairs, where the key is the Id of the input. If `data` is expressed as a string, input values are not sent to the Bot. + /// + [JsonPropertyName("data")] + public IUnion? Data { get; set; } + + /// + /// The Ids of the inputs associated with the Action.Submit. When the action is executed, the values of the associated inputs are sent to the Bot. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// + [JsonPropertyName("associatedInputs")] + public AssociatedInputs? AssociatedInputs { get; set; } + + /// + /// Controls if the action is enabled only if at least one required input has been filled by the user. + /// + [JsonPropertyName("conditionallyEnabled")] + public bool? ConditionallyEnabled { get; set; } = false; + + /// + /// Teams-specific metadata associated with the action. + /// + [JsonPropertyName("msteams")] + public TeamsSubmitActionProperties? Msteams { get; set; } + + /// + /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + /// + /// Serializes this SubmitAction into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public SubmitAction WithKey(string value) + { + this.Key = value; + return this; + } + + public SubmitAction WithId(string value) + { + this.Id = value; + return this; + } + + public SubmitAction WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public SubmitAction WithTitle(string value) + { + this.Title = value; + return this; + } + + public SubmitAction WithIconUrl(string value) + { + this.IconUrl = value; + return this; + } + + public SubmitAction WithStyle(ActionStyle value) + { + this.Style = value; + return this; + } + + public SubmitAction WithMode(ActionMode value) + { + this.Mode = value; + return this; + } + + public SubmitAction WithTooltip(string value) + { + this.Tooltip = value; + return this; + } + + public SubmitAction WithIsEnabled(bool value) + { + this.IsEnabled = value; + return this; + } + + public SubmitAction WithMenuActions(params Action[] value) + { + this.MenuActions = new List(value); + return this; + } + + public SubmitAction WithMenuActions(IList value) + { + this.MenuActions = value; + return this; + } + + public SubmitAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public SubmitAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + + public SubmitAction WithData(IUnion value) + { + this.Data = value; + return this; + } + + public SubmitAction WithAssociatedInputs(AssociatedInputs value) + { + this.AssociatedInputs = value; + return this; + } + + public SubmitAction WithConditionallyEnabled(bool value) + { + this.ConditionallyEnabled = value; + return this; + } + + public SubmitAction WithMsteams(TeamsSubmitActionProperties value) + { + this.Msteams = value; + return this; + } + + public SubmitAction WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} + +/// +/// Toggles the visibility of a set of elements. Action.ToggleVisibility is useful for creating "Show more" type UI patterns. +/// +public class ToggleVisibilityAction : Action +{ + /// + /// Deserializes a JSON string into an object of type ToggleVisibilityAction. + /// + public static ToggleVisibilityAction? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Action.ToggleVisibility**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Action.ToggleVisibility"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The title of the action, as it appears on buttons. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. + /// + /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } + + /// + /// Control the style of the action, affecting its visual and spoken representations. + /// + [JsonPropertyName("style")] + public ActionStyle? Style { get; set; } = ActionStyle.Default; + + /// + /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// + [JsonPropertyName("mode")] + public ActionMode? Mode { get; set; } = ActionMode.Primary; + + /// + /// The tooltip text to display when the action is hovered over. + /// + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } + + /// + /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } = true; + + /// + /// The actions to display in the overflow menu of a Split action button. + /// + [JsonPropertyName("menuActions")] + public IList? MenuActions { get; set; } + + /// + /// A set of theme-specific icon URLs. + /// + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } + + /// + /// The Ids of the elements to toggle the visibility of. + /// + [JsonPropertyName("targetElements")] + public IUnion, IList>? TargetElements { get; set; } + + /// + /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + /// + /// Serializes this ToggleVisibilityAction into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public ToggleVisibilityAction WithKey(string value) + { + this.Key = value; + return this; + } + + public ToggleVisibilityAction WithId(string value) + { + this.Id = value; + return this; + } + + public ToggleVisibilityAction WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public ToggleVisibilityAction WithTitle(string value) + { + this.Title = value; + return this; + } + + public ToggleVisibilityAction WithIconUrl(string value) + { + this.IconUrl = value; + return this; + } + + public ToggleVisibilityAction WithStyle(ActionStyle value) + { + this.Style = value; + return this; + } + + public ToggleVisibilityAction WithMode(ActionMode value) + { + this.Mode = value; + return this; + } + + public ToggleVisibilityAction WithTooltip(string value) + { + this.Tooltip = value; + return this; + } + + public ToggleVisibilityAction WithIsEnabled(bool value) + { + this.IsEnabled = value; + return this; + } + + public ToggleVisibilityAction WithMenuActions(params Action[] value) + { + this.MenuActions = new List(value); + return this; + } + + public ToggleVisibilityAction WithMenuActions(IList value) + { + this.MenuActions = value; + return this; + } + + public ToggleVisibilityAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public ToggleVisibilityAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + + public ToggleVisibilityAction WithTargetElements(IUnion, IList> value) + { + this.TargetElements = value; + return this; + } + + public ToggleVisibilityAction WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} + +/// +/// Defines a theme-specific URL. +/// +public class ThemedUrl : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type ThemedUrl. + /// + public static ThemedUrl? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The theme this URL applies to. + /// + [JsonPropertyName("theme")] + public ThemeName? Theme { get; set; } = ThemeName.Light; + + /// + /// The URL to use for the associated theme. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Serializes this ThemedUrl into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public ThemedUrl WithKey(string value) + { + this.Key = value; + return this; + } + + public ThemedUrl WithTheme(ThemeName value) + { + this.Theme = value; + return this; + } + + public ThemedUrl WithUrl(string value) + { + this.Url = value; + return this; + } +} + +/// +/// Defines a target element in an Action.ToggleVisibility. +/// +public class TargetElement : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type TargetElement. + /// + public static TargetElement? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// The Id of the element to change the visibility of. + /// + [JsonPropertyName("elementId")] + public string? ElementId { get; set; } + + /// + /// The new visibility state of the element. + /// + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } + + /// + /// Serializes this TargetElement into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public TargetElement WithElementId(string value) + { + this.ElementId = value; + return this; + } + + public TargetElement WithIsVisible(bool value) + { + this.IsVisible = value; + return this; + } +} + +/// +/// Expands or collapses an embedded card within the main card. +/// +public class ShowCardAction : Action +{ + /// + /// Deserializes a JSON string into an object of type ShowCardAction. + /// + public static ShowCardAction? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Action.ShowCard**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Action.ShowCard"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The title of the action, as it appears on buttons. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. + /// + /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } + + /// + /// Control the style of the action, affecting its visual and spoken representations. + /// + [JsonPropertyName("style")] + public ActionStyle? Style { get; set; } = ActionStyle.Default; + + /// + /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// + [JsonPropertyName("mode")] + public ActionMode? Mode { get; set; } = ActionMode.Primary; + + /// + /// The tooltip text to display when the action is hovered over. + /// + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } + + /// + /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } = true; + + /// + /// The actions to display in the overflow menu of a Split action button. + /// + [JsonPropertyName("menuActions")] + public IList? MenuActions { get; set; } + + /// + /// A set of theme-specific icon URLs. + /// + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } + + /// + /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + /// + /// The card that should be displayed when the action is executed. + /// + [JsonPropertyName("card")] + public AdaptiveCard? Card { get; set; } + + /// + /// Serializes this ShowCardAction into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public ShowCardAction WithKey(string value) + { + this.Key = value; + return this; + } + + public ShowCardAction WithId(string value) + { + this.Id = value; + return this; + } + + public ShowCardAction WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public ShowCardAction WithTitle(string value) + { + this.Title = value; + return this; + } + + public ShowCardAction WithIconUrl(string value) + { + this.IconUrl = value; + return this; + } + + public ShowCardAction WithStyle(ActionStyle value) + { + this.Style = value; + return this; + } + + public ShowCardAction WithMode(ActionMode value) + { + this.Mode = value; + return this; + } + + public ShowCardAction WithTooltip(string value) + { + this.Tooltip = value; + return this; + } + + public ShowCardAction WithIsEnabled(bool value) + { + this.IsEnabled = value; + return this; + } + + public ShowCardAction WithMenuActions(params Action[] value) + { + this.MenuActions = new List(value); + return this; + } + + public ShowCardAction WithMenuActions(IList value) + { + this.MenuActions = value; + return this; + } + + public ShowCardAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public ShowCardAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + + public ShowCardAction WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } + + public ShowCardAction WithCard(AdaptiveCard value) + { + this.Card = value; + return this; + } +} + +/// +/// Shows a popover to display more information to the user. +/// +public class PopoverAction : Action +{ + /// + /// Deserializes a JSON string into an object of type PopoverAction. + /// + public static PopoverAction? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Action.Popover**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Action.Popover"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The title of the action, as it appears on buttons. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. + /// + /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } + + /// + /// Control the style of the action, affecting its visual and spoken representations. + /// + [JsonPropertyName("style")] + public ActionStyle? Style { get; set; } = ActionStyle.Default; + + /// + /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// + [JsonPropertyName("mode")] + public ActionMode? Mode { get; set; } = ActionMode.Primary; + + /// + /// The tooltip text to display when the action is hovered over. + /// + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } + + /// + /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } = true; + + /// + /// A set of theme-specific icon URLs. + /// + [JsonPropertyName("themedIconUrls")] + public IList? ThemedIconUrls { get; set; } + + /// + /// The content of the popover, which can be any element. + /// + [JsonPropertyName("content")] + public CardElement? Content { get; set; } + + /// + /// Controls if an arrow should be displayed towards the element that triggered the popover. + /// + [JsonPropertyName("displayArrow")] + public bool? DisplayArrow { get; set; } = true; + + /// + /// Controls where the popover should be displayed with regards to the element that triggered it. + /// + [JsonPropertyName("position")] + public PopoverPosition? Position { get; set; } = PopoverPosition.Above; + + /// + /// The maximum width of the popover in pixels, in the `px` format + /// + [JsonPropertyName("maxPopoverWidth")] + public string? MaxPopoverWidth { get; set; } + + /// + /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + /// + /// Serializes this PopoverAction into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public PopoverAction WithKey(string value) + { + this.Key = value; + return this; + } + + public PopoverAction WithId(string value) + { + this.Id = value; + return this; + } + + public PopoverAction WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public PopoverAction WithTitle(string value) + { + this.Title = value; + return this; + } + + public PopoverAction WithIconUrl(string value) + { + this.IconUrl = value; + return this; + } + + public PopoverAction WithStyle(ActionStyle value) + { + this.Style = value; + return this; + } + + public PopoverAction WithMode(ActionMode value) + { + this.Mode = value; + return this; + } + + public PopoverAction WithTooltip(string value) + { + this.Tooltip = value; + return this; + } + + public PopoverAction WithIsEnabled(bool value) + { + this.IsEnabled = value; + return this; + } + + public PopoverAction WithThemedIconUrls(params ThemedUrl[] value) + { + this.ThemedIconUrls = new List(value); + return this; + } + + public PopoverAction WithThemedIconUrls(IList value) + { + this.ThemedIconUrls = value; + return this; + } + + public PopoverAction WithContent(CardElement value) + { + this.Content = value; + return this; + } + + public PopoverAction WithDisplayArrow(bool value) + { + this.DisplayArrow = value; + return this; + } + + public PopoverAction WithPosition(PopoverPosition value) + { + this.Position = value; + return this; + } + + public PopoverAction WithMaxPopoverWidth(string value) + { + this.MaxPopoverWidth = value; + return this; + } + + public PopoverAction WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} + +/// +/// Displays a set of action, which can be placed anywhere in the card. +/// +public class ActionSet : CardElement +{ + /// + /// Deserializes a JSON string into an object of type ActionSet. + /// + public static ActionSet? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **ActionSet**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "ActionSet"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The locale associated with the element. + /// + [JsonPropertyName("lang")] + public string? Lang { get; set; } + + /// + /// Controls the visibility of the element. + /// + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } = true; + + /// + /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. + /// + [JsonPropertyName("separator")] + public bool? Separator { get; set; } = false; + + /// + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; + + /// + /// Controls how the element should be horizontally aligned. + /// + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } + + /// + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. + /// + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; + + /// + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). + /// + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } + + /// + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; + + /// + /// The area of a Layout.AreaGrid layout in which an element should be displayed. + /// + [JsonPropertyName("grid.area")] + public string? GridArea { get; set; } + + /// + /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + /// + /// The actions in the set. + /// + [JsonPropertyName("actions")] + public IList? Actions { get; set; } + + public ActionSet() { } + + public ActionSet(params Action[] actions) + { + this.Actions = new List(actions); + } + + public ActionSet(IList actions) + { + this.Actions = actions; + } + + /// + /// Serializes this ActionSet into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public ActionSet WithKey(string value) + { + this.Key = value; + return this; + } + + public ActionSet WithId(string value) + { + this.Id = value; + return this; + } + + public ActionSet WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public ActionSet WithLang(string value) + { + this.Lang = value; + return this; + } + + public ActionSet WithIsVisible(bool value) + { + this.IsVisible = value; + return this; + } + + public ActionSet WithSeparator(bool value) + { + this.Separator = value; + return this; + } + + public ActionSet WithHeight(ElementHeight value) + { + this.Height = value; + return this; + } + + public ActionSet WithHorizontalAlignment(HorizontalAlignment value) + { + this.HorizontalAlignment = value; + return this; + } + + public ActionSet WithSpacing(Spacing value) + { + this.Spacing = value; + return this; + } + + public ActionSet WithTargetWidth(TargetWidth value) + { + this.TargetWidth = value; + return this; + } + + public ActionSet WithIsSortKey(bool value) + { + this.IsSortKey = value; + return this; + } + + public ActionSet WithGridArea(string value) + { + this.GridArea = value; + return this; + } + + public ActionSet WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } + + public ActionSet WithActions(params Action[] value) + { + this.Actions = new List(value); + return this; + } + + public ActionSet WithActions(IList value) + { + this.Actions = value; + return this; + } +} + +/// +/// A container for other elements. Use containers for styling purposes and/or to logically group a set of elements together, which can be especially useful when used with Action.ToggleVisibility. +/// +public class Container : CardElement +{ + /// + /// Deserializes a JSON string into an object of type Container. + /// + public static Container? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Container**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Container"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The locale associated with the element. + /// + [JsonPropertyName("lang")] + public string? Lang { get; set; } + + /// + /// Controls the visibility of the element. + /// + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } = true; + + /// + /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. + /// + [JsonPropertyName("separator")] + public bool? Separator { get; set; } = false; + + /// + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; + + /// + /// Controls how the element should be horizontally aligned. + /// + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } + + /// + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. + /// + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; + + /// + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). + /// + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } + + /// + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// - [JsonPropertyName("type")] - public string Type { get; } = "task/fetch"; + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; /// - /// Serializes this TaskFetchSubmitActionData into a JSON string. + /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } -} + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } -/// -/// Gathers input values, merges them with the data property if specified, and sends them to the Bot via an Invoke activity. The Bot can only acknowledge is has received the request. -/// -public class SubmitAction : Action -{ /// - /// Deserializes a JSON string into an object of type SubmitAction. + /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. /// - public static SubmitAction? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("style")] + public ContainerStyle? Style { get; set; } /// - /// Must be **Action.Submit**. + /// Controls if a border should be displayed around the container. /// - [JsonPropertyName("type")] - public string Type { get; } = "Action.Submit"; + [JsonPropertyName("showBorder")] + public bool? ShowBorder { get; set; } = false; /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// Controls if the container should have rounded corners. /// - [JsonPropertyName("id")] - public string? Id { get; set; } + [JsonPropertyName("roundedCorners")] + public bool? RoundedCorners { get; set; } = false; /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// The layouts associated with the container. The container can dynamically switch from one layout to another as the card's width changes. See [Container layouts](https://adaptivecards.microsoft.com/?topic=container-layouts) for more details. /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + [JsonPropertyName("layouts")] + public IList? Layouts { get; set; } /// - /// The title of the action, as it appears on buttons. + /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. /// - [JsonPropertyName("title")] - public string? Title { get; set; } + [JsonPropertyName("bleed")] + public bool? Bleed { get; set; } = false; /// - /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. - /// - /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// The minimum height, in pixels, of the container, in the `px` format. /// - [JsonPropertyName("iconUrl")] - public string? IconUrl { get; set; } + [JsonPropertyName("minHeight")] + public string? MinHeight { get; set; } /// - /// Control the style of the action, affecting its visual and spoken representations. + /// Defines the container's background image. /// - [JsonPropertyName("style")] - public ActionStyle? Style { get; set; } + [JsonPropertyName("backgroundImage")] + public IUnion? BackgroundImage { get; set; } /// - /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// Controls how the container's content should be vertically aligned. /// - [JsonPropertyName("mode")] - public ActionMode? Mode { get; set; } + [JsonPropertyName("verticalContentAlignment")] + public VerticalAlignment? VerticalContentAlignment { get; set; } /// - /// The tooltip text to display when the action is hovered over. + /// Controls if the content of the card is to be rendered left-to-right or right-to-left. /// - [JsonPropertyName("tooltip")] - public string? Tooltip { get; set; } + [JsonPropertyName("rtl")] + public bool? Rtl { get; set; } /// - /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// The maximum height, in pixels, of the container, in the `px` format. When the content of a container exceeds the container's maximum height, a vertical scrollbar is displayed. /// - [JsonPropertyName("isEnabled")] - public bool? IsEnabled { get; set; } + [JsonPropertyName("maxHeight")] + public string? MaxHeight { get; set; } /// - /// The data to send to the Bot when the action is executed. When expressed as an object, `data` is sent back to the Bot when the action is executed, adorned with the values of the inputs expressed as key/value pairs, where the key is the Id of the input. If `data` is expressed as a string, input values are not sent to the Bot. + /// The area of a Layout.AreaGrid layout in which an element should be displayed. /// - [JsonPropertyName("data")] - public IUnion? Data { get; set; } + [JsonPropertyName("grid.area")] + public string? GridArea { get; set; } /// - /// The Ids of the inputs associated with the Action.Submit. When the action is executed, the values of the associated inputs are sent to the Bot. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. /// - [JsonPropertyName("associatedInputs")] - public AssociatedInputs? AssociatedInputs { get; set; } + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } /// - /// Controls if the action is enabled only if at least one required input has been filled by the user. + /// The elements in the container. /// - [JsonPropertyName("conditionallyEnabled")] - public bool? ConditionallyEnabled { get; set; } + [JsonPropertyName("items")] + public IList? Items { get; set; } - [JsonPropertyName("msteams")] - public TeamsSubmitActionProperties? Msteams { get; set; } + public Container() { } - /// - /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. - /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + public Container(params CardElement[] items) + { + this.Items = new List(items); + } + + public Container(IList items) + { + this.Items = items; + } /// - /// Serializes this SubmitAction into a JSON string. + /// Serializes this Container into a JSON string. /// public string Serialize() { @@ -1936,147 +4147,202 @@ public string Serialize() ); } - public SubmitAction WithId(string value) + public Container WithKey(string value) + { + this.Key = value; + return this; + } + + public Container WithId(string value) { this.Id = value; return this; } - public SubmitAction WithRequires(HostCapabilities value) + public Container WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public SubmitAction WithTitle(string value) + public Container WithLang(string value) { - this.Title = value; + this.Lang = value; return this; } - public SubmitAction WithIconUrl(string value) + public Container WithIsVisible(bool value) { - this.IconUrl = value; + this.IsVisible = value; return this; } - public SubmitAction WithStyle(ActionStyle value) + public Container WithSeparator(bool value) + { + this.Separator = value; + return this; + } + + public Container WithHeight(ElementHeight value) + { + this.Height = value; + return this; + } + + public Container WithHorizontalAlignment(HorizontalAlignment value) + { + this.HorizontalAlignment = value; + return this; + } + + public Container WithSpacing(Spacing value) + { + this.Spacing = value; + return this; + } + + public Container WithTargetWidth(TargetWidth value) + { + this.TargetWidth = value; + return this; + } + + public Container WithIsSortKey(bool value) + { + this.IsSortKey = value; + return this; + } + + public Container WithSelectAction(Action value) + { + this.SelectAction = value; + return this; + } + + public Container WithStyle(ContainerStyle value) { this.Style = value; return this; } - public SubmitAction WithMode(ActionMode value) + public Container WithShowBorder(bool value) { - this.Mode = value; + this.ShowBorder = value; return this; } - public SubmitAction WithTooltip(string value) + public Container WithRoundedCorners(bool value) { - this.Tooltip = value; + this.RoundedCorners = value; return this; } - public SubmitAction WithIsEnabled(bool value) + public Container WithLayouts(params ContainerLayout[] value) { - this.IsEnabled = value; + this.Layouts = new List(value); return this; } - public SubmitAction WithData(IUnion value) + public Container WithLayouts(IList value) { - this.Data = value; + this.Layouts = value; return this; } - public SubmitAction WithAssociatedInputs(AssociatedInputs value) + public Container WithBleed(bool value) { - this.AssociatedInputs = value; + this.Bleed = value; return this; } - public SubmitAction WithConditionallyEnabled(bool value) + public Container WithMinHeight(string value) { - this.ConditionallyEnabled = value; + this.MinHeight = value; return this; } - public SubmitAction WithMsteams(TeamsSubmitActionProperties value) + public Container WithBackgroundImage(IUnion value) { - this.Msteams = value; + this.BackgroundImage = value; return this; } - public SubmitAction WithFallback(IUnion value) + public Container WithVerticalContentAlignment(VerticalAlignment value) { - this.Fallback = value; + this.VerticalContentAlignment = value; return this; } -} -/// -/// Teams-specific properties associated with the action. -/// -public class TeamsSubmitActionProperties : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type TeamsSubmitActionProperties. - /// - public static TeamsSubmitActionProperties? Deserialize(string json) + public Container WithRtl(bool value) { - return JsonSerializer.Deserialize(json); + this.Rtl = value; + return this; } - /// - /// Defines how feedback is provided to the end-user when the action is executed. - /// - [JsonPropertyName("feedback")] - public TeamsSubmitActionFeedback? Feedback { get; set; } + public Container WithMaxHeight(string value) + { + this.MaxHeight = value; + return this; + } - /// - /// Serializes this TeamsSubmitActionProperties into a JSON string. - /// - public string Serialize() + public Container WithGridArea(string value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.GridArea = value; + return this; } - public TeamsSubmitActionProperties WithFeedback(TeamsSubmitActionFeedback value) + public Container WithFallback(IUnion value) { - this.Feedback = value; + this.Fallback = value; + return this; + } + + public Container WithItems(params CardElement[] value) + { + this.Items = new List(value); + return this; + } + + public Container WithItems(IList value) + { + this.Items = value; return this; } } /// -/// Represents feedback options for an [Action.Submit](https://adaptivecards.microsoft.com/?topic=Action.Submit). +/// A layout that stacks elements on top of each other. Layout.Stack is the default layout used by AdaptiveCard and all containers. /// -public class TeamsSubmitActionFeedback : SerializableObject +public class StackLayout : ContainerLayout { /// - /// Deserializes a JSON string into an object of type TeamsSubmitActionFeedback. + /// Deserializes a JSON string into an object of type StackLayout. /// - public static TeamsSubmitActionFeedback? Deserialize(string json) + public static StackLayout? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Defines if a feedback message should be displayed after the action is executed. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("hide")] - public bool? Hide { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Serializes this TeamsSubmitActionFeedback into a JSON string. + /// Must be **Layout.Stack**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Layout.Stack"; + + /// + /// Controls for which card width the layout should be used. + /// + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } + + /// + /// Serializes this StackLayout into a JSON string. /// public string Serialize() { @@ -2090,101 +4356,100 @@ public string Serialize() ); } - public TeamsSubmitActionFeedback WithHide(bool value) + public StackLayout WithKey(string value) { - this.Hide = value; + this.Key = value; + return this; + } + + public StackLayout WithTargetWidth(TargetWidth value) + { + this.TargetWidth = value; return this; } } /// -/// Opens the provided URL in either a separate browser tab or within the host application. +/// A layout that spreads elements horizontally and wraps them across multiple rows, as needed. /// -public class OpenUrlAction : Action +public class FlowLayout : ContainerLayout { /// - /// Deserializes a JSON string into an object of type OpenUrlAction. + /// Deserializes a JSON string into an object of type FlowLayout. /// - public static OpenUrlAction? Deserialize(string json) + public static FlowLayout? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Action.OpenUrl**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("type")] - public string Type { get; } = "Action.OpenUrl"; + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// Must be **Layout.Flow**. /// - [JsonPropertyName("id")] - public string? Id { get; set; } + [JsonPropertyName("type")] + public string Type { get; } = "Layout.Flow"; /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// Controls for which card width the layout should be used. /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } /// - /// The title of the action, as it appears on buttons. + /// Controls how the content of the container should be horizontally aligned. /// - [JsonPropertyName("title")] - public string? Title { get; set; } + [JsonPropertyName("horizontalItemsAlignment")] + public HorizontalAlignment? HorizontalItemsAlignment { get; set; } = HorizontalAlignment.Center; /// - /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. - /// - /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// Controls how the content of the container should be vertically aligned. /// - [JsonPropertyName("iconUrl")] - public string? IconUrl { get; set; } + [JsonPropertyName("verticalItemsAlignment")] + public VerticalAlignment? VerticalItemsAlignment { get; set; } = VerticalAlignment.Top; /// - /// Control the style of the action, affecting its visual and spoken representations. + /// Controls how item should fit inside the container. /// - [JsonPropertyName("style")] - public ActionStyle? Style { get; set; } + [JsonPropertyName("itemFit")] + public FlowLayoutItemFit? ItemFit { get; set; } = FlowLayoutItemFit.Fit; /// - /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// The minimum width, in pixels, of each item, in the `px` format. Should not be used if itemWidth is set. /// - [JsonPropertyName("mode")] - public ActionMode? Mode { get; set; } + [JsonPropertyName("minItemWidth")] + public string? MinItemWidth { get; set; } /// - /// The tooltip text to display when the action is hovered over. + /// The maximum width, in pixels, of each item, in the `px` format. Should not be used if itemWidth is set. /// - [JsonPropertyName("tooltip")] - public string? Tooltip { get; set; } + [JsonPropertyName("maxItemWidth")] + public string? MaxItemWidth { get; set; } /// - /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// The width, in pixels, of each item, in the `px` format. Should not be used if maxItemWidth and/or minItemWidth are set. /// - [JsonPropertyName("isEnabled")] - public bool? IsEnabled { get; set; } + [JsonPropertyName("itemWidth")] + public string? ItemWidth { get; set; } /// - /// The URL to open. + /// The space between items. /// - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonPropertyName("columnSpacing")] + public Spacing? ColumnSpacing { get; set; } = Spacing.Default; /// - /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The space between rows of items. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } - - public OpenUrlAction(string url) - { - this.Url = url; - } + [JsonPropertyName("rowSpacing")] + public Spacing? RowSpacing { get; set; } = Spacing.Default; /// - /// Serializes this OpenUrlAction into a JSON string. + /// Serializes this FlowLayout into a JSON string. /// public string Serialize() { @@ -2198,150 +4463,124 @@ public string Serialize() ); } - public OpenUrlAction WithId(string value) + public FlowLayout WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public OpenUrlAction WithRequires(HostCapabilities value) + public FlowLayout WithTargetWidth(TargetWidth value) { - this.Requires = value; + this.TargetWidth = value; return this; } - public OpenUrlAction WithTitle(string value) + public FlowLayout WithHorizontalItemsAlignment(HorizontalAlignment value) { - this.Title = value; + this.HorizontalItemsAlignment = value; return this; } - public OpenUrlAction WithIconUrl(string value) + public FlowLayout WithVerticalItemsAlignment(VerticalAlignment value) { - this.IconUrl = value; + this.VerticalItemsAlignment = value; return this; } - public OpenUrlAction WithStyle(ActionStyle value) + public FlowLayout WithItemFit(FlowLayoutItemFit value) { - this.Style = value; + this.ItemFit = value; return this; } - public OpenUrlAction WithMode(ActionMode value) + public FlowLayout WithMinItemWidth(string value) { - this.Mode = value; + this.MinItemWidth = value; return this; } - public OpenUrlAction WithTooltip(string value) + public FlowLayout WithMaxItemWidth(string value) { - this.Tooltip = value; + this.MaxItemWidth = value; return this; } - public OpenUrlAction WithIsEnabled(bool value) + public FlowLayout WithItemWidth(string value) { - this.IsEnabled = value; + this.ItemWidth = value; return this; } - public OpenUrlAction WithUrl(string value) + public FlowLayout WithColumnSpacing(Spacing value) { - this.Url = value; + this.ColumnSpacing = value; return this; } - public OpenUrlAction WithFallback(IUnion value) + public FlowLayout WithRowSpacing(Spacing value) { - this.Fallback = value; + this.RowSpacing = value; return this; } } /// -/// Toggles the visibility of a set of elements. Action.ToggleVisibility is useful for creating "Show more" type UI patterns. +/// A layout that divides a container into named areas into which elements can be placed. /// -public class ToggleVisibilityAction : Action +public class AreaGridLayout : ContainerLayout { /// - /// Deserializes a JSON string into an object of type ToggleVisibilityAction. + /// Deserializes a JSON string into an object of type AreaGridLayout. /// - public static ToggleVisibilityAction? Deserialize(string json) + public static AreaGridLayout? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Action.ToggleVisibility**. - /// - [JsonPropertyName("type")] - public string Type { get; } = "Action.ToggleVisibility"; - - /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). - /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } - - /// - /// The title of the action, as it appears on buttons. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. - /// - /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("iconUrl")] - public string? IconUrl { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Control the style of the action, affecting its visual and spoken representations. + /// Must be **Layout.AreaGrid**. /// - [JsonPropertyName("style")] - public ActionStyle? Style { get; set; } + [JsonPropertyName("type")] + public string Type { get; } = "Layout.AreaGrid"; /// - /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// Controls for which card width the layout should be used. /// - [JsonPropertyName("mode")] - public ActionMode? Mode { get; set; } + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } /// - /// The tooltip text to display when the action is hovered over. + /// The columns in the grid layout, defined as a percentage of the available width or in pixels using the `px` format. /// - [JsonPropertyName("tooltip")] - public string? Tooltip { get; set; } + [JsonPropertyName("columns")] + public IUnion, IList>? Columns { get; set; } /// - /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// The areas in the grid layout. /// - [JsonPropertyName("isEnabled")] - public bool? IsEnabled { get; set; } + [JsonPropertyName("areas")] + public IList? Areas { get; set; } /// - /// The Ids of the elements to toggle the visibility of. + /// The space between columns. /// - [JsonPropertyName("targetElements")] - public IUnion, IList>? TargetElements { get; set; } + [JsonPropertyName("columnSpacing")] + public Spacing? ColumnSpacing { get; set; } = Spacing.Default; /// - /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The space between rows. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("rowSpacing")] + public Spacing? RowSpacing { get; set; } = Spacing.Default; /// - /// Serializes this ToggleVisibilityAction into a JSON string. + /// Serializes this AreaGridLayout into a JSON string. /// public string Serialize() { @@ -2355,94 +4594,100 @@ public string Serialize() ); } - public ToggleVisibilityAction WithId(string value) - { - this.Id = value; - return this; - } - - public ToggleVisibilityAction WithRequires(HostCapabilities value) - { - this.Requires = value; - return this; - } - - public ToggleVisibilityAction WithTitle(string value) - { - this.Title = value; - return this; - } - - public ToggleVisibilityAction WithIconUrl(string value) + public AreaGridLayout WithKey(string value) { - this.IconUrl = value; + this.Key = value; return this; } - public ToggleVisibilityAction WithStyle(ActionStyle value) + public AreaGridLayout WithTargetWidth(TargetWidth value) { - this.Style = value; + this.TargetWidth = value; return this; } - public ToggleVisibilityAction WithMode(ActionMode value) + public AreaGridLayout WithColumns(IUnion, IList> value) { - this.Mode = value; + this.Columns = value; return this; } - public ToggleVisibilityAction WithTooltip(string value) + public AreaGridLayout WithAreas(params GridArea[] value) { - this.Tooltip = value; + this.Areas = new List(value); return this; } - public ToggleVisibilityAction WithIsEnabled(bool value) + public AreaGridLayout WithAreas(IList value) { - this.IsEnabled = value; + this.Areas = value; return this; } - public ToggleVisibilityAction WithTargetElements(IUnion, IList> value) + public AreaGridLayout WithColumnSpacing(Spacing value) { - this.TargetElements = value; + this.ColumnSpacing = value; return this; } - public ToggleVisibilityAction WithFallback(IUnion value) + public AreaGridLayout WithRowSpacing(Spacing value) { - this.Fallback = value; + this.RowSpacing = value; return this; } } /// -/// Defines a target element in an Action.ToggleVisibility. +/// Defines an area in a Layout.AreaGrid layout. /// -public class TargetElement : SerializableObject +public class GridArea : SerializableObject { /// - /// Deserializes a JSON string into an object of type TargetElement. + /// Deserializes a JSON string into an object of type GridArea. /// - public static TargetElement? Deserialize(string json) + public static GridArea? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The Id of the element to change the visibility of. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("elementId")] - public string? ElementId { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The new visibility state of the element. + /// The name of the area. To place an element in this area, set its `grid.area` property to match the name of the area. /// - [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// - /// Serializes this TargetElement into a JSON string. + /// The start column index of the area. Column indices start at 1. + /// + [JsonPropertyName("column")] + public float? Column { get; set; } = 1; + + /// + /// Defines how many columns the area should span. + /// + [JsonPropertyName("columnSpan")] + public float? ColumnSpan { get; set; } = 1; + + /// + /// The start row index of the area. Row indices start at 1. + /// + [JsonPropertyName("row")] + public float? Row { get; set; } = 1; + + /// + /// Defines how many rows the area should span. + /// + [JsonPropertyName("rowSpan")] + public float? RowSpan { get; set; } = 1; + + /// + /// Serializes this GridArea into a JSON string. /// public string Serialize() { @@ -2456,102 +4701,94 @@ public string Serialize() ); } - public TargetElement WithElementId(string value) + public GridArea WithKey(string value) { - this.ElementId = value; + this.Key = value; return this; } - public TargetElement WithIsVisible(bool value) + public GridArea WithName(string value) { - this.IsVisible = value; + this.Name = value; return this; } -} -/// -/// Expands or collapses an embedded card within the main card. -/// -public class ShowCardAction : Action -{ - /// - /// Deserializes a JSON string into an object of type ShowCardAction. - /// - public static ShowCardAction? Deserialize(string json) + public GridArea WithColumn(float value) { - return JsonSerializer.Deserialize(json); + this.Column = value; + return this; } - /// - /// Must be **Action.ShowCard**. - /// - [JsonPropertyName("type")] - public string Type { get; } = "Action.ShowCard"; - - /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } + public GridArea WithColumnSpan(float value) + { + this.ColumnSpan = value; + return this; + } - /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). - /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public GridArea WithRow(float value) + { + this.Row = value; + return this; + } - /// - /// The title of the action, as it appears on buttons. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } + public GridArea WithRowSpan(float value) + { + this.RowSpan = value; + return this; + } +} +/// +/// Defines a container's background image and the way it should be rendered. +/// +public class BackgroundImage : SerializableObject +{ /// - /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. - /// - /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// Deserializes a JSON string into an object of type BackgroundImage. /// - [JsonPropertyName("iconUrl")] - public string? IconUrl { get; set; } + public static BackgroundImage? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } /// - /// Control the style of the action, affecting its visual and spoken representations. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("style")] - public ActionStyle? Style { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// The URL (or Base64-encoded Data URI) of the image. Acceptable formats are PNG, JPEG, GIF and SVG. /// - [JsonPropertyName("mode")] - public ActionMode? Mode { get; set; } + [JsonPropertyName("url")] + public string? Url { get; set; } /// - /// The tooltip text to display when the action is hovered over. + /// Controls how the image should fill the area. /// - [JsonPropertyName("tooltip")] - public string? Tooltip { get; set; } + [JsonPropertyName("fillMode")] + public FillMode? FillMode { get; set; } = FillMode.Cover; /// - /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// Controls how the image should be aligned if it must be cropped or if using repeat fill mode. /// - [JsonPropertyName("isEnabled")] - public bool? IsEnabled { get; set; } + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } = HorizontalAlignment.Left; /// - /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// Controls how the image should be aligned if it must be cropped or if using repeat fill mode. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("verticalAlignment")] + public VerticalAlignment? VerticalAlignment { get; set; } = VerticalAlignment.Top; /// - /// The card that should be displayed when the action is executed. + /// A set of theme-specific image URLs. /// - [JsonPropertyName("card")] - public AdaptiveCard? Card { get; set; } + [JsonPropertyName("themedUrls")] + public IList? ThemedUrls { get; set; } /// - /// Serializes this ShowCardAction into a JSON string. + /// Serializes this BackgroundImage into a JSON string. /// public string Serialize() { @@ -2565,85 +4802,73 @@ public string Serialize() ); } - public ShowCardAction WithId(string value) - { - this.Id = value; - return this; - } - - public ShowCardAction WithRequires(HostCapabilities value) - { - this.Requires = value; - return this; - } - - public ShowCardAction WithTitle(string value) - { - this.Title = value; - return this; - } - - public ShowCardAction WithIconUrl(string value) + public BackgroundImage WithKey(string value) { - this.IconUrl = value; + this.Key = value; return this; } - public ShowCardAction WithStyle(ActionStyle value) + public BackgroundImage WithUrl(string value) { - this.Style = value; + this.Url = value; return this; } - public ShowCardAction WithMode(ActionMode value) + public BackgroundImage WithFillMode(FillMode value) { - this.Mode = value; + this.FillMode = value; return this; } - public ShowCardAction WithTooltip(string value) + public BackgroundImage WithHorizontalAlignment(HorizontalAlignment value) { - this.Tooltip = value; + this.HorizontalAlignment = value; return this; } - public ShowCardAction WithIsEnabled(bool value) + public BackgroundImage WithVerticalAlignment(VerticalAlignment value) { - this.IsEnabled = value; + this.VerticalAlignment = value; return this; } - public ShowCardAction WithFallback(IUnion value) + public BackgroundImage WithThemedUrls(params ThemedUrl[] value) { - this.Fallback = value; + this.ThemedUrls = new List(value); return this; } - public ShowCardAction WithCard(AdaptiveCard value) + public BackgroundImage WithThemedUrls(IList value) { - this.Card = value; + this.ThemedUrls = value; return this; } } /// -/// Resets the values of the inputs in the card. +/// Splits the available horizontal space into separate columns, so elements can be organized in a row. /// -public class ResetInputsAction : Action +public class ColumnSet : CardElement { /// - /// Deserializes a JSON string into an object of type ResetInputsAction. + /// Deserializes a JSON string into an object of type ColumnSet. /// - public static ResetInputsAction? Deserialize(string json) + public static ColumnSet? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Action.ResetInputs**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **ColumnSet**. /// [JsonPropertyName("type")] - public string Type { get; } = "Action.ResetInputs"; + public string Type { get; } = "ColumnSet"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -2655,60 +4880,118 @@ public class ResetInputsAction : Action /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The locale associated with the element. + /// + [JsonPropertyName("lang")] + public string? Lang { get; set; } + + /// + /// Controls the visibility of the element. + /// + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } = true; + + /// + /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. + /// + [JsonPropertyName("separator")] + public bool? Separator { get; set; } = false; + + /// + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; + + /// + /// Controls how the element should be horizontally aligned. + /// + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } + + /// + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. + /// + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; + + /// + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). + /// + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } + + /// + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; + + /// + /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. + /// + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } + + /// + /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. + /// + [JsonPropertyName("style")] + public ContainerStyle? Style { get; set; } /// - /// The title of the action, as it appears on buttons. + /// Controls if a border should be displayed around the container. /// - [JsonPropertyName("title")] - public string? Title { get; set; } + [JsonPropertyName("showBorder")] + public bool? ShowBorder { get; set; } = false; /// - /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. - /// - /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// Controls if the container should have rounded corners. /// - [JsonPropertyName("iconUrl")] - public string? IconUrl { get; set; } + [JsonPropertyName("roundedCorners")] + public bool? RoundedCorners { get; set; } = false; /// - /// Control the style of the action, affecting its visual and spoken representations. + /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. /// - [JsonPropertyName("style")] - public ActionStyle? Style { get; set; } + [JsonPropertyName("bleed")] + public bool? Bleed { get; set; } = false; /// - /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// The minimum height, in pixels, of the container, in the `px` format. /// - [JsonPropertyName("mode")] - public ActionMode? Mode { get; set; } + [JsonPropertyName("minHeight")] + public string? MinHeight { get; set; } /// - /// The tooltip text to display when the action is hovered over. + /// The minimum width of the column set. `auto` will automatically adjust the column set's minimum width according to its content and using the `px` format will give the column set an explicit minimum width in pixels. A scrollbar will be displayed if the available width is less than the specified minimum width. /// - [JsonPropertyName("tooltip")] - public string? Tooltip { get; set; } + [JsonPropertyName("minWidth")] + public string? MinWidth { get; set; } /// - /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// The area of a Layout.AreaGrid layout in which an element should be displayed. /// - [JsonPropertyName("isEnabled")] - public bool? IsEnabled { get; set; } + [JsonPropertyName("grid.area")] + public string? GridArea { get; set; } /// - /// The Ids of the inputs that should be reset. + /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. /// - [JsonPropertyName("targetInputIds")] - public IList? TargetInputIds { get; set; } + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } /// - /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The columns in the set. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("columns")] + public IList? Columns { get; set; } /// - /// Serializes this ResetInputsAction into a JSON string. + /// Serializes this ColumnSet into a JSON string. /// public string Serialize() { @@ -2722,85 +5005,163 @@ public string Serialize() ); } - public ResetInputsAction WithId(string value) + public ColumnSet WithKey(string value) + { + this.Key = value; + return this; + } + + public ColumnSet WithId(string value) { this.Id = value; return this; } - public ResetInputsAction WithRequires(HostCapabilities value) + public ColumnSet WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ResetInputsAction WithTitle(string value) + public ColumnSet WithLang(string value) { - this.Title = value; + this.Lang = value; return this; } - public ResetInputsAction WithIconUrl(string value) + public ColumnSet WithIsVisible(bool value) { - this.IconUrl = value; + this.IsVisible = value; return this; } - public ResetInputsAction WithStyle(ActionStyle value) + public ColumnSet WithSeparator(bool value) + { + this.Separator = value; + return this; + } + + public ColumnSet WithHeight(ElementHeight value) + { + this.Height = value; + return this; + } + + public ColumnSet WithHorizontalAlignment(HorizontalAlignment value) + { + this.HorizontalAlignment = value; + return this; + } + + public ColumnSet WithSpacing(Spacing value) + { + this.Spacing = value; + return this; + } + + public ColumnSet WithTargetWidth(TargetWidth value) + { + this.TargetWidth = value; + return this; + } + + public ColumnSet WithIsSortKey(bool value) + { + this.IsSortKey = value; + return this; + } + + public ColumnSet WithSelectAction(Action value) + { + this.SelectAction = value; + return this; + } + + public ColumnSet WithStyle(ContainerStyle value) { this.Style = value; return this; } - public ResetInputsAction WithMode(ActionMode value) + public ColumnSet WithShowBorder(bool value) { - this.Mode = value; + this.ShowBorder = value; return this; } - public ResetInputsAction WithTooltip(string value) + public ColumnSet WithRoundedCorners(bool value) { - this.Tooltip = value; + this.RoundedCorners = value; return this; } - public ResetInputsAction WithIsEnabled(bool value) + public ColumnSet WithBleed(bool value) { - this.IsEnabled = value; + this.Bleed = value; return this; } - public ResetInputsAction WithTargetInputIds(params IList value) + public ColumnSet WithMinHeight(string value) { - this.TargetInputIds = value; + this.MinHeight = value; return this; } - public ResetInputsAction WithFallback(IUnion value) + public ColumnSet WithMinWidth(string value) + { + this.MinWidth = value; + return this; + } + + public ColumnSet WithGridArea(string value) + { + this.GridArea = value; + return this; + } + + public ColumnSet WithFallback(IUnion value) { this.Fallback = value; return this; } + + public ColumnSet WithColumns(params Column[] value) + { + this.Columns = new List(value); + return this; + } + + public ColumnSet WithColumns(IList value) + { + this.Columns = value; + return this; + } } /// -/// Inserts an image into the host application's canvas. +/// A media element, that makes it possible to embed videos inside a card. /// -public class InsertImageAction : Action +public class Media : CardElement { /// - /// Deserializes a JSON string into an object of type InsertImageAction. + /// Deserializes a JSON string into an object of type Media. /// - public static InsertImageAction? Deserialize(string json) + public static Media? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Action.InsertImage**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Media**. /// [JsonPropertyName("type")] - public string Type { get; } = "Action.InsertImage"; + public string Type { get; } = "Media"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -2812,152 +5173,204 @@ public class InsertImageAction : Action /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// - /// The title of the action, as it appears on buttons. + /// The locale associated with the element. /// - [JsonPropertyName("title")] - public string? Title { get; set; } + [JsonPropertyName("lang")] + public string? Lang { get; set; } /// - /// A URL (or Base64-encoded Data URI) to a PNG, GIF, JPEG or SVG image to be displayed on the left of the action's title. - /// - /// `iconUrl` also accepts the `[,regular|filled]` format to display an icon from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) instead of an image. + /// Controls the visibility of the element. /// - [JsonPropertyName("iconUrl")] - public string? IconUrl { get; set; } + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } = true; /// - /// Control the style of the action, affecting its visual and spoken representations. + /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// - [JsonPropertyName("style")] - public ActionStyle? Style { get; set; } + [JsonPropertyName("separator")] + public bool? Separator { get; set; } = false; /// - /// Controls if the action is primary or secondary. Secondary actions appear in an overflow menu. + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// - [JsonPropertyName("mode")] - public ActionMode? Mode { get; set; } + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// - /// The tooltip text to display when the action is hovered over. + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// - [JsonPropertyName("tooltip")] - public string? Tooltip { get; set; } + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; /// - /// Controls the enabled state of the action. A disabled action cannot be clicked. If the action is represented as a button, the button's style will reflect this state. + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). /// - [JsonPropertyName("isEnabled")] - public bool? IsEnabled { get; set; } + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } /// - /// The URL of the image to insert. + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; /// - /// The alternate text for the image. + /// The sources for the media. For YouTube, Dailymotion and Vimeo, only one source can be specified. + /// + [JsonPropertyName("sources")] + public IList? Sources { get; set; } + + /// + /// The caption sources for the media. Caption sources are not used for YouTube, Dailymotion or Vimeo sources. + /// + [JsonPropertyName("captionSources")] + public IList? CaptionSources { get; set; } + + /// + /// The URL of the poster image to display. + /// + [JsonPropertyName("poster")] + public string? Poster { get; set; } + + /// + /// The alternate text for the media, used for accessibility purposes. /// [JsonPropertyName("altText")] public string? AltText { get; set; } /// - /// The position at which to insert the image. + /// The area of a Layout.AreaGrid layout in which an element should be displayed. /// - [JsonPropertyName("insertPosition")] - public ImageInsertPosition? InsertPosition { get; set; } + [JsonPropertyName("grid.area")] + public string? GridArea { get; set; } /// - /// An alternate action to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. /// [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + public IUnion? Fallback { get; set; } + + /// + /// Serializes this Media into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public Media WithKey(string value) + { + this.Key = value; + return this; + } + + public Media WithId(string value) + { + this.Id = value; + return this; + } + + public Media WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public Media WithLang(string value) + { + this.Lang = value; + return this; + } - /// - /// Serializes this InsertImageAction into a JSON string. - /// - public string Serialize() + public Media WithIsVisible(bool value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.IsVisible = value; + return this; } - public InsertImageAction WithId(string value) + public Media WithSeparator(bool value) { - this.Id = value; + this.Separator = value; return this; } - public InsertImageAction WithRequires(HostCapabilities value) + public Media WithHeight(ElementHeight value) { - this.Requires = value; + this.Height = value; return this; } - public InsertImageAction WithTitle(string value) + public Media WithSpacing(Spacing value) { - this.Title = value; + this.Spacing = value; return this; } - public InsertImageAction WithIconUrl(string value) + public Media WithTargetWidth(TargetWidth value) { - this.IconUrl = value; + this.TargetWidth = value; return this; } - public InsertImageAction WithStyle(ActionStyle value) + public Media WithIsSortKey(bool value) { - this.Style = value; + this.IsSortKey = value; return this; } - public InsertImageAction WithMode(ActionMode value) + public Media WithSources(params MediaSource[] value) { - this.Mode = value; + this.Sources = new List(value); return this; } - public InsertImageAction WithTooltip(string value) + public Media WithSources(IList value) { - this.Tooltip = value; + this.Sources = value; return this; } - public InsertImageAction WithIsEnabled(bool value) + public Media WithCaptionSources(params CaptionSource[] value) { - this.IsEnabled = value; + this.CaptionSources = new List(value); return this; } - public InsertImageAction WithUrl(string value) + public Media WithCaptionSources(IList value) { - this.Url = value; + this.CaptionSources = value; return this; } - public InsertImageAction WithAltText(string value) + public Media WithPoster(string value) + { + this.Poster = value; + return this; + } + + public Media WithAltText(string value) { this.AltText = value; return this; } - public InsertImageAction WithInsertPosition(ImageInsertPosition value) + public Media WithGridArea(string value) { - this.InsertPosition = value; + this.GridArea = value; return this; } - public InsertImageAction WithFallback(IUnion value) + public Media WithFallback(IUnion value) { this.Fallback = value; return this; @@ -2965,32 +5378,38 @@ public InsertImageAction WithFallback(IUnion value) } /// -/// A layout that stacks elements on top of each other. Layout.Stack is the default layout used by AdaptiveCard and all containers. +/// Defines the source URL of a media stream. YouTube, Dailymotion, Vimeo and Microsoft Stream URLs are supported. /// -public class StackLayout : ContainerLayout +public class MediaSource : SerializableObject { /// - /// Deserializes a JSON string into an object of type StackLayout. + /// Deserializes a JSON string into an object of type MediaSource. /// - public static StackLayout? Deserialize(string json) + public static MediaSource? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Layout.Stack**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("type")] - public string Type { get; } = "Layout.Stack"; + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Controls for which card width the layout should be used. + /// The MIME type of the source. /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } /// - /// Serializes this StackLayout into a JSON string. + /// The URL of the source. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Serializes this MediaSource into a JSON string. /// public string Serialize() { @@ -3004,88 +5423,64 @@ public string Serialize() ); } - public StackLayout WithTargetWidth(TargetWidth value) + public MediaSource WithKey(string value) { - this.TargetWidth = value; + this.Key = value; + return this; + } + + public MediaSource WithMimeType(string value) + { + this.MimeType = value; + return this; + } + + public MediaSource WithUrl(string value) + { + this.Url = value; return this; } } /// -/// A layout that spreads elements horizontally and wraps them across multiple rows, as needed. +/// Defines a source URL for a video captions. /// -public class FlowLayout : ContainerLayout +public class CaptionSource : SerializableObject { /// - /// Deserializes a JSON string into an object of type FlowLayout. + /// Deserializes a JSON string into an object of type CaptionSource. /// - public static FlowLayout? Deserialize(string json) + public static CaptionSource? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Layout.Flow**. - /// - [JsonPropertyName("type")] - public string Type { get; } = "Layout.Flow"; - - /// - /// Controls for which card width the layout should be used. - /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } - - /// - /// Controls how the content of the container should be horizontally aligned. - /// - [JsonPropertyName("horizontalItemsAlignment")] - public HorizontalAlignment? HorizontalItemsAlignment { get; set; } - - /// - /// Controls how the content of the container should be vertically aligned. - /// - [JsonPropertyName("verticalItemsAlignment")] - public VerticalAlignment? VerticalItemsAlignment { get; set; } - - /// - /// Controls how item should fit inside the container. - /// - [JsonPropertyName("itemFit")] - public FlowLayoutItemFit? ItemFit { get; set; } - - /// - /// The minimum width, in pixels, of each item, in the `px` format. Should not be used if itemWidth is set. - /// - [JsonPropertyName("minItemWidth")] - public string? MinItemWidth { get; set; } - - /// - /// The maximum width, in pixels, of each item, in the `px` format. Should not be used if itemWidth is set. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("maxItemWidth")] - public string? MaxItemWidth { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The width, in pixels, of each item, in the `px` format. Should not be used if maxItemWidth and/or minItemWidth are set. + /// The MIME type of the source. /// - [JsonPropertyName("itemWidth")] - public string? ItemWidth { get; set; } + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } /// - /// The space between items. + /// The URL of the source. /// - [JsonPropertyName("columnSpacing")] - public Spacing? ColumnSpacing { get; set; } + [JsonPropertyName("url")] + public string? Url { get; set; } /// - /// The space between rows of items. + /// The label of this caption source. /// - [JsonPropertyName("rowSpacing")] - public Spacing? RowSpacing { get; set; } + [JsonPropertyName("label")] + public string? Label { get; set; } /// - /// Serializes this FlowLayout into a JSON string. + /// Serializes this CaptionSource into a JSON string. /// public string Serialize() { @@ -3099,112 +5494,142 @@ public string Serialize() ); } - public FlowLayout WithTargetWidth(TargetWidth value) + public CaptionSource WithKey(string value) { - this.TargetWidth = value; + this.Key = value; return this; } - public FlowLayout WithHorizontalItemsAlignment(HorizontalAlignment value) + public CaptionSource WithMimeType(string value) { - this.HorizontalItemsAlignment = value; + this.MimeType = value; return this; } - public FlowLayout WithVerticalItemsAlignment(VerticalAlignment value) + public CaptionSource WithUrl(string value) { - this.VerticalItemsAlignment = value; + this.Url = value; return this; } - public FlowLayout WithItemFit(FlowLayoutItemFit value) + public CaptionSource WithLabel(string value) { - this.ItemFit = value; + this.Label = value; return this; } +} - public FlowLayout WithMinItemWidth(string value) +/// +/// A rich text block that displays formatted text. +/// +public class RichTextBlock : CardElement +{ + /// + /// Deserializes a JSON string into an object of type RichTextBlock. + /// + public static RichTextBlock? Deserialize(string json) { - this.MinItemWidth = value; - return this; + return JsonSerializer.Deserialize(json); } - public FlowLayout WithMaxItemWidth(string value) - { - this.MaxItemWidth = value; - return this; - } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } - public FlowLayout WithItemWidth(string value) - { - this.ItemWidth = value; - return this; - } + /// + /// Must be **RichTextBlock**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "RichTextBlock"; - public FlowLayout WithColumnSpacing(Spacing value) - { - this.ColumnSpacing = value; - return this; - } + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } - public FlowLayout WithRowSpacing(Spacing value) - { - this.RowSpacing = value; - return this; - } -} + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); + + /// + /// The locale associated with the element. + /// + [JsonPropertyName("lang")] + public string? Lang { get; set; } + + /// + /// Controls the visibility of the element. + /// + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } = true; + + /// + /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. + /// + [JsonPropertyName("separator")] + public bool? Separator { get; set; } = false; + + /// + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; -/// -/// A layout that divides a container into named areas into which elements can be placed. -/// -public class AreaGridLayout : ContainerLayout -{ /// - /// Deserializes a JSON string into an object of type AreaGridLayout. + /// Controls how the element should be horizontally aligned. /// - public static AreaGridLayout? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// - /// Must be **Layout.AreaGrid**. + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// - [JsonPropertyName("type")] - public string Type { get; } = "Layout.AreaGrid"; + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; /// - /// Controls for which card width the layout should be used. + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). /// [JsonPropertyName("targetWidth")] public TargetWidth? TargetWidth { get; set; } /// - /// The columns in the grid layout, defined as a percentage of the available width or in pixels using the `px` format. + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// - [JsonPropertyName("columns")] - public IUnion, IList>? Columns { get; set; } + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; /// - /// The areas in the grid layout. + /// The Id of the input the RichTextBlock should act as the label of. /// - [JsonPropertyName("areas")] - public IList? Areas { get; set; } + [JsonPropertyName("labelFor")] + public string? LabelFor { get; set; } /// - /// The space between columns. + /// The area of a Layout.AreaGrid layout in which an element should be displayed. /// - [JsonPropertyName("columnSpacing")] - public Spacing? ColumnSpacing { get; set; } + [JsonPropertyName("grid.area")] + public string? GridArea { get; set; } /// - /// The space between rows. + /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. /// - [JsonPropertyName("rowSpacing")] - public Spacing? RowSpacing { get; set; } + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } /// - /// Serializes this AreaGridLayout into a JSON string. + /// The inlines making up the rich text block. + /// + [JsonPropertyName("inlines")] + public IUnion, IList>? Inlines { get; set; } + + /// + /// Serializes this RichTextBlock into a JSON string. /// public string Serialize() { @@ -3218,295 +5643,262 @@ public string Serialize() ); } - public AreaGridLayout WithTargetWidth(TargetWidth value) + public RichTextBlock WithKey(string value) { - this.TargetWidth = value; + this.Key = value; return this; } - public AreaGridLayout WithColumns(IUnion, IList> value) + public RichTextBlock WithId(string value) { - this.Columns = value; + this.Id = value; return this; } - public AreaGridLayout WithAreas(params IList value) + public RichTextBlock WithRequires(HostCapabilities value) { - this.Areas = value; + this.Requires = value; return this; } - public AreaGridLayout WithColumnSpacing(Spacing value) + public RichTextBlock WithLang(string value) { - this.ColumnSpacing = value; + this.Lang = value; return this; } - public AreaGridLayout WithRowSpacing(Spacing value) + public RichTextBlock WithIsVisible(bool value) { - this.RowSpacing = value; + this.IsVisible = value; return this; } -} -/// -/// Defines an area in a Layout.AreaGrid layout. -/// -public class GridArea : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type GridArea. - /// - public static GridArea? Deserialize(string json) + public RichTextBlock WithSeparator(bool value) { - return JsonSerializer.Deserialize(json); + this.Separator = value; + return this; } - /// - /// The name of the area. To place an element in this area, set its `grid.area` property to match the name of the area. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// The start column index of the area. Column indices start at 1. - /// - [JsonPropertyName("column")] - public float? Column { get; set; } - - /// - /// Defines how many columns the area should span. - /// - [JsonPropertyName("columnSpan")] - public float? ColumnSpan { get; set; } + public RichTextBlock WithHeight(ElementHeight value) + { + this.Height = value; + return this; + } - /// - /// The start row index of the area. Row indices start at 1. - /// - [JsonPropertyName("row")] - public float? Row { get; set; } + public RichTextBlock WithHorizontalAlignment(HorizontalAlignment value) + { + this.HorizontalAlignment = value; + return this; + } - /// - /// Defines how many rows the area should span. - /// - [JsonPropertyName("rowSpan")] - public float? RowSpan { get; set; } + public RichTextBlock WithSpacing(Spacing value) + { + this.Spacing = value; + return this; + } - /// - /// Serializes this GridArea into a JSON string. - /// - public string Serialize() + public RichTextBlock WithTargetWidth(TargetWidth value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.TargetWidth = value; + return this; } - public GridArea WithName(string value) + public RichTextBlock WithIsSortKey(bool value) { - this.Name = value; + this.IsSortKey = value; return this; } - public GridArea WithColumn(float value) + public RichTextBlock WithLabelFor(string value) { - this.Column = value; + this.LabelFor = value; return this; } - public GridArea WithColumnSpan(float value) + public RichTextBlock WithGridArea(string value) { - this.ColumnSpan = value; + this.GridArea = value; return this; } - public GridArea WithRow(float value) + public RichTextBlock WithFallback(IUnion value) { - this.Row = value; + this.Fallback = value; return this; } - public GridArea WithRowSpan(float value) + public RichTextBlock WithInlines(IUnion, IList> value) { - this.RowSpan = value; + this.Inlines = value; return this; } } /// -/// Defines a container's background image and the way it should be rendered. +/// Use tables to display data in a tabular way, with rows, columns and cells. /// -public class BackgroundImage : SerializableObject +public class Table : CardElement { /// - /// Deserializes a JSON string into an object of type BackgroundImage. + /// Deserializes a JSON string into an object of type Table. /// - public static BackgroundImage? Deserialize(string json) + public static Table? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The URL (or Base64-encoded Data URI) of the image. Acceptable formats are PNG, JPEG, GIF and SVG. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Controls how the image should fill the area. + /// Must be **Table**. /// - [JsonPropertyName("fillMode")] - public FillMode? FillMode { get; set; } + [JsonPropertyName("type")] + public string Type { get; } = "Table"; /// - /// Controls how the image should be aligned if it must be cropped or if using repeat fill mode. + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + [JsonPropertyName("id")] + public string? Id { get; set; } /// - /// Controls how the image should be aligned if it must be cropped or if using repeat fill mode. + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// - [JsonPropertyName("verticalAlignment")] - public VerticalAlignment? VerticalAlignment { get; set; } + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// - /// Serializes this BackgroundImage into a JSON string. + /// The locale associated with the element. /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } + [JsonPropertyName("lang")] + public string? Lang { get; set; } - public BackgroundImage WithUrl(string value) - { - this.Url = value; - return this; - } + /// + /// Controls the visibility of the element. + /// + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } = true; - public BackgroundImage WithFillMode(FillMode value) - { - this.FillMode = value; - return this; - } + /// + /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. + /// + [JsonPropertyName("separator")] + public bool? Separator { get; set; } = false; - public BackgroundImage WithHorizontalAlignment(HorizontalAlignment value) - { - this.HorizontalAlignment = value; - return this; - } + /// + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; - public BackgroundImage WithVerticalAlignment(VerticalAlignment value) - { - this.VerticalAlignment = value; - return this; - } -} + /// + /// Controls how the element should be horizontally aligned. + /// + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } + + /// + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. + /// + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; + + /// + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). + /// + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } + + /// + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; + + /// + /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. + /// + [JsonPropertyName("style")] + public ContainerStyle? Style { get; set; } + + /// + /// Controls if a border should be displayed around the container. + /// + [JsonPropertyName("showBorder")] + public bool? ShowBorder { get; set; } = false; -/// -/// Defines how a card can be refreshed by making a request to the target Bot. -/// -public class RefreshDefinition : SerializableObject -{ /// - /// Deserializes a JSON string into an object of type RefreshDefinition. + /// Controls if the container should have rounded corners. /// - public static RefreshDefinition? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("roundedCorners")] + public bool? RoundedCorners { get; set; } = false; /// - /// The Action.Execute action to invoke to refresh the card. + /// The columns in the table. /// - [JsonPropertyName("action")] - public ExecuteAction? Action { get; set; } + [JsonPropertyName("columns")] + public IList? Columns { get; set; } /// - /// The list of user Ids for which the card will be automatically refreshed. In Teams, in chats or channels with more than 60 users, the card will automatically refresh only for users specified in the userIds list. Other users will have to manually click on a "refresh" button. In contexts with fewer than 60 users, the card will automatically refresh for all users. + /// The minimum width of the table in pixels. `auto` will automatically adjust the table's minimum width according to its content and using the `px` format will give the table an explicit minimum width in pixels. A scrollbar will be displayed if the available width is less than the specified minimum width. /// - [JsonPropertyName("userIds")] - public IList? UserIds { get; set; } + [JsonPropertyName("minWidth")] + public string? MinWidth { get; set; } /// - /// Serializes this RefreshDefinition into a JSON string. + /// Controls whether the first row of the table should be treated as a header. /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } + [JsonPropertyName("firstRowAsHeaders")] + public bool? FirstRowAsHeaders { get; set; } = true; - public RefreshDefinition WithAction(ExecuteAction value) - { - this.Action = value; - return this; - } + /// + /// Controls if grid lines should be displayed. + /// + [JsonPropertyName("showGridLines")] + public bool? ShowGridLines { get; set; } = true; - public RefreshDefinition WithUserIds(params IList value) - { - this.UserIds = value; - return this; - } -} + /// + /// The style of the grid lines between cells. + /// + [JsonPropertyName("gridStyle")] + public ContainerStyle? GridStyle { get; set; } -/// -/// Defines authentication information associated with a card. For more information, refer to the [Bot Framework OAuthCard type](https://docs.microsoft.com/dotnet/api/microsoft.bot.schema.oauthcard) -/// -public class Authentication : SerializableObject -{ /// - /// Deserializes a JSON string into an object of type Authentication. + /// Controls how the content of every cell in the table should be horizontally aligned by default. /// - public static Authentication? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("horizontalCellContentAlignment")] + public HorizontalAlignment? HorizontalCellContentAlignment { get; set; } /// - /// The text that can be displayed to the end user when prompting them to authenticate. + /// Controls how the content of every cell in the table should be vertically aligned by default. /// - [JsonPropertyName("text")] - public string? Text { get; set; } + [JsonPropertyName("verticalCellContentAlignment")] + public VerticalAlignment? VerticalCellContentAlignment { get; set; } /// - /// The identifier for registered OAuth connection setting information. + /// The area of a Layout.AreaGrid layout in which an element should be displayed. /// - [JsonPropertyName("connectionName")] - public string? ConnectionName { get; set; } + [JsonPropertyName("grid.area")] + public string? GridArea { get; set; } /// - /// The buttons that should be displayed to the user when prompting for authentication. The array MUST contain one button of type “signin”. Other button types are not currently supported. + /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. /// - [JsonPropertyName("buttons")] - public IList? Buttons { get; set; } + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } /// - /// Provides information required to enable on-behalf-of single sign-on user authentication. + /// The rows of the table. /// - [JsonPropertyName("tokenExchangeResource")] - public TokenExchangeResource? TokenExchangeResource { get; set; } + [JsonPropertyName("rows")] + public IList? Rows { get; set; } /// - /// Serializes this Authentication into a JSON string. + /// Serializes this Table into a JSON string. /// public string Serialize() { @@ -3520,320 +5912,202 @@ public string Serialize() ); } - public Authentication WithText(string value) + public Table WithKey(string value) { - this.Text = value; + this.Key = value; return this; } - public Authentication WithConnectionName(string value) + public Table WithId(string value) { - this.ConnectionName = value; + this.Id = value; return this; } - public Authentication WithButtons(params IList value) + public Table WithRequires(HostCapabilities value) { - this.Buttons = value; + this.Requires = value; return this; } - public Authentication WithTokenExchangeResource(TokenExchangeResource value) + public Table WithLang(string value) { - this.TokenExchangeResource = value; + this.Lang = value; return this; } -} -/// -/// Defines a button as displayed when prompting a user to authenticate. For more information, refer to the [Bot Framework CardAction type](https://docs.microsoft.com/dotnet/api/microsoft.bot.schema.cardaction). -/// -public class AuthCardButton : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type AuthCardButton. - /// - public static AuthCardButton? Deserialize(string json) + public Table WithIsVisible(bool value) { - return JsonSerializer.Deserialize(json); + this.IsVisible = value; + return this; } - /// - /// Must be **signin**. - /// - [JsonPropertyName("type")] - public string? Type { get; set; } - - /// - /// The caption of the button. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// A URL to an image to display alongside the button’s caption. - /// - [JsonPropertyName("image")] - public string? Image { get; set; } - - /// - /// The value associated with the button. The meaning of value depends on the button’s type. - /// - [JsonPropertyName("value")] - public string? Value { get; set; } - - /// - /// Serializes this AuthCardButton into a JSON string. - /// - public string Serialize() + public Table WithSeparator(bool value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Separator = value; + return this; } - public AuthCardButton WithType(string value) + public Table WithHeight(ElementHeight value) { - this.Type = value; + this.Height = value; return this; } - public AuthCardButton WithTitle(string value) + public Table WithHorizontalAlignment(HorizontalAlignment value) { - this.Title = value; + this.HorizontalAlignment = value; return this; } - public AuthCardButton WithImage(string value) + public Table WithSpacing(Spacing value) { - this.Image = value; + this.Spacing = value; return this; } - public AuthCardButton WithValue(string value) + public Table WithTargetWidth(TargetWidth value) { - this.Value = value; + this.TargetWidth = value; return this; } -} -/// -/// Defines information required to enable on-behalf-of single sign-on user authentication. For more information, refer to the [Bot Framework TokenExchangeResource type](https://docs.microsoft.com/dotnet/api/microsoft.bot.schema.tokenexchangeresource) -/// -public class TokenExchangeResource : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type TokenExchangeResource. - /// - public static TokenExchangeResource? Deserialize(string json) + public Table WithIsSortKey(bool value) { - return JsonSerializer.Deserialize(json); + this.IsSortKey = value; + return this; } - /// - /// The unique identified of this token exchange instance. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// An application ID or resource identifier with which to exchange a token on behalf of. This property is identity provider- and application-specific. - /// - [JsonPropertyName("uri")] - public string? Uri { get; set; } - - /// - /// An identifier for the identity provider with which to attempt a token exchange. - /// - [JsonPropertyName("providerId")] - public string? ProviderId { get; set; } - - /// - /// Serializes this TokenExchangeResource into a JSON string. - /// - public string Serialize() + public Table WithStyle(ContainerStyle value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Style = value; + return this; } - public TokenExchangeResource WithId(string value) + public Table WithShowBorder(bool value) { - this.Id = value; + this.ShowBorder = value; return this; } - public TokenExchangeResource WithUri(string value) + public Table WithRoundedCorners(bool value) { - this.Uri = value; + this.RoundedCorners = value; return this; } - public TokenExchangeResource WithProviderId(string value) + public Table WithColumns(params ColumnDefinition[] value) { - this.ProviderId = value; + this.Columns = new List(value); return this; } -} -/// -/// Represents a set of Teams-specific properties on a card. -/// -public class TeamsCardProperties : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type TeamsCardProperties. - /// - public static TeamsCardProperties? Deserialize(string json) + public Table WithColumns(IList value) { - return JsonSerializer.Deserialize(json); - } - - /// - /// Controls the width of the card in a Teams chat. - /// - /// Note that setting `width` to "full" will not actually stretch the card to the "full width" of the chat pane. It will only make the card wider than when the `width` property isn't set. - /// - [JsonPropertyName("width")] - public TeamsCardWidth? Width { get; set; } - - /// - /// The Teams-specific entities associated with the card. - /// - [JsonPropertyName("entities")] - public IList? Entities { get; set; } + this.Columns = value; + return this; + } - /// - /// Serializes this TeamsCardProperties into a JSON string. - /// - public string Serialize() + public Table WithMinWidth(string value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.MinWidth = value; + return this; } - public TeamsCardProperties WithWidth(TeamsCardWidth value) + public Table WithFirstRowAsHeaders(bool value) { - this.Width = value; + this.FirstRowAsHeaders = value; return this; } - public TeamsCardProperties WithEntities(params IList value) + public Table WithShowGridLines(bool value) { - this.Entities = value; + this.ShowGridLines = value; return this; } -} -/// -/// Represents a mention to a person. -/// -public class Mention : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type Mention. - /// - public static Mention? Deserialize(string json) + public Table WithGridStyle(ContainerStyle value) { - return JsonSerializer.Deserialize(json); + this.GridStyle = value; + return this; } - /// - /// Must be **mention**. - /// - [JsonPropertyName("type")] - public string Type { get; } = "mention"; + public Table WithHorizontalCellContentAlignment(HorizontalAlignment value) + { + this.HorizontalCellContentAlignment = value; + return this; + } - /// - /// The text that will be substituted with the mention. - /// - [JsonPropertyName("text")] - public string? Text { get; set; } + public Table WithVerticalCellContentAlignment(VerticalAlignment value) + { + this.VerticalCellContentAlignment = value; + return this; + } - /// - /// Defines the entity being mentioned. - /// - [JsonPropertyName("mentioned")] - public MentionedEntity? Mentioned { get; set; } + public Table WithGridArea(string value) + { + this.GridArea = value; + return this; + } - /// - /// Serializes this Mention into a JSON string. - /// - public string Serialize() + public Table WithFallback(IUnion value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Fallback = value; + return this; } - public Mention WithText(string value) + public Table WithRows(params TableRow[] value) { - this.Text = value; + this.Rows = new List(value); return this; } - public Mention WithMentioned(MentionedEntity value) + public Table WithRows(IList value) { - this.Mentioned = value; + this.Rows = value; return this; } } /// -/// Represents a mentioned person or tag. +/// Defines a column in a Table element. /// -public class MentionedEntity : SerializableObject +public class ColumnDefinition : SerializableObject { /// - /// Deserializes a JSON string into an object of type MentionedEntity. + /// Deserializes a JSON string into an object of type ColumnDefinition. /// - public static MentionedEntity? Deserialize(string json) + public static ColumnDefinition? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The Id of a person (typically a Microsoft Entra user Id) or tag. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("id")] - public string? Id { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The name of the mentioned entity. + /// Controls how the content of every cell in the table should be horizontally aligned by default. This property overrides the horizontalCellContentAlignment property of the table. /// - [JsonPropertyName("name")] - public string? Name { get; set; } + [JsonPropertyName("horizontalCellContentAlignment")] + public HorizontalAlignment? HorizontalCellContentAlignment { get; set; } /// - /// The type of the mentioned entity. + /// Controls how the content of every cell in the column should be vertically aligned by default. This property overrides the verticalCellContentAlignment property of the table. /// - [JsonPropertyName("mentionType")] - public MentionType? MentionType { get; set; } + [JsonPropertyName("verticalCellContentAlignment")] + public VerticalAlignment? VerticalCellContentAlignment { get; set; } /// - /// Serializes this MentionedEntity into a JSON string. + /// The width of the column in the table. If expressed as a number, represents the relative weight of the column in the table. If expressed as a string, `auto` will automatically adjust the column's width according to its content and using the `px` format will give the column an explicit width in pixels. + /// + [JsonPropertyName("width")] + public IUnion? Width { get; set; } + + /// + /// Serializes this ColumnDefinition into a JSON string. /// public string Serialize() { @@ -3847,84 +6121,55 @@ public string Serialize() ); } - public MentionedEntity WithId(string value) + public ColumnDefinition WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public MentionedEntity WithName(string value) + public ColumnDefinition WithHorizontalCellContentAlignment(HorizontalAlignment value) { - this.Name = value; + this.HorizontalCellContentAlignment = value; return this; } - public MentionedEntity WithMentionType(MentionType value) + public ColumnDefinition WithVerticalCellContentAlignment(VerticalAlignment value) { - this.MentionType = value; + this.VerticalCellContentAlignment = value; return this; } -} - -/// -/// Card-level metadata. -/// -public class CardMetadata : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type CardMetadata. - /// - public static CardMetadata? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } - - /// - /// The URL the card originates from. When `webUrl` is set, the card is dubbed an **Adaptive Card-based Loop Component** and, when pasted in Teams or other Loop Component-capable host applications, the URL will unfurl to the same exact card. - /// - [JsonPropertyName("webUrl")] - public string? WebUrl { get; set; } - - /// - /// Serializes this CardMetadata into a JSON string. - /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } - public CardMetadata WithWebUrl(string value) + public ColumnDefinition WithWidth(IUnion value) { - this.WebUrl = value; + this.Width = value; return this; } } /// -/// A container for other elements. Use containers for styling purposes and/or to logically group a set of elements together, which can be especially useful when used with Action.ToggleVisibility. +/// A block of text, optionally formatted using Markdown. /// -public class Container : CardElement +public class TextBlock : CardElement { /// - /// Deserializes a JSON string into an object of type Container. + /// Deserializes a JSON string into an object of type TextBlock. /// - public static Container? Deserialize(string json) + public static TextBlock? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Container**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **TextBlock**. /// [JsonPropertyName("type")] - public string Type { get; } = "Container"; + public string Type { get; } = "TextBlock"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -3936,7 +6181,7 @@ public class Container : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -3948,19 +6193,19 @@ public class Container : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -3972,7 +6217,7 @@ public class Container : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -3984,73 +6229,67 @@ public class Container : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. - /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } - - /// - /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. + /// The text to display. A subset of markdown is supported. /// - [JsonPropertyName("style")] - public ContainerStyle? Style { get; set; } + [JsonPropertyName("text")] + public string? Text { get; set; } /// - /// Controls if a border should be displayed around the container. + /// The size of the text. /// - [JsonPropertyName("showBorder")] - public bool? ShowBorder { get; set; } + [JsonPropertyName("size")] + public TextSize? Size { get; set; } /// - /// Controls if the container should have rounded corners. + /// The weight of the text. /// - [JsonPropertyName("roundedCorners")] - public bool? RoundedCorners { get; set; } + [JsonPropertyName("weight")] + public TextWeight? Weight { get; set; } /// - /// The layouts associated with the container. The container can dynamically switch from one layout to another as the card's width changes. See [Container layouts](https://adaptivecards.microsoft.com/?topic=container-layouts) for more details. + /// The color of the text. /// - [JsonPropertyName("layouts")] - public IList? Layouts { get; set; } + [JsonPropertyName("color")] + public TextColor? Color { get; set; } /// - /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. + /// Controls whether the text should be renderer using a subtler variant of the select color. /// - [JsonPropertyName("bleed")] - public bool? Bleed { get; set; } + [JsonPropertyName("isSubtle")] + public bool? IsSubtle { get; set; } /// - /// The minimum height, in pixels, of the container, in the `px` format. + /// The type of font to use for rendering. /// - [JsonPropertyName("minHeight")] - public string? MinHeight { get; set; } + [JsonPropertyName("fontType")] + public FontType? FontType { get; set; } /// - /// Defines the container's background image. + /// Controls if the text should wrap. /// - [JsonPropertyName("backgroundImage")] - public IUnion? BackgroundImage { get; set; } + [JsonPropertyName("wrap")] + public bool? Wrap { get; set; } = false; /// - /// Controls how the container's content should be vertically aligned. + /// The maximum number of lines to display. /// - [JsonPropertyName("verticalContentAlignment")] - public VerticalAlignment? VerticalContentAlignment { get; set; } + [JsonPropertyName("maxLines")] + public float? MaxLines { get; set; } /// - /// Controls if the content of the card is to be rendered left-to-right or right-to-left. + /// The style of the text. /// - [JsonPropertyName("rtl")] - public bool? Rtl { get; set; } + [JsonPropertyName("style")] + public TextBlockStyle? Style { get; set; } /// - /// The maximum height, in pixels, of the container, in the `px` format. When the content of a container exceeds the container's maximum height, a vertical scrollbar is displayed. + /// The Id of the input the TextBlock should act as the label of. /// - [JsonPropertyName("maxHeight")] - public string? MaxHeight { get; set; } + [JsonPropertyName("labelFor")] + public string? LabelFor { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -4064,19 +6303,15 @@ public class Container : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - /// - /// The elements in the container. - /// - [JsonPropertyName("items")] - public IList? Items { get; set; } + public TextBlock() { } - public Container(params IList items) + public TextBlock(string text) { - this.Items = items; + this.Text = text; } /// - /// Serializes this Container into a JSON string. + /// Serializes this TextBlock into a JSON string. /// public string Serialize() { @@ -4090,169 +6325,169 @@ public string Serialize() ); } - public Container WithId(string value) + public TextBlock WithKey(string value) + { + this.Key = value; + return this; + } + + public TextBlock WithId(string value) { this.Id = value; return this; } - public Container WithRequires(HostCapabilities value) + public TextBlock WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public Container WithLang(string value) + public TextBlock WithLang(string value) { this.Lang = value; return this; } - public Container WithIsVisible(bool value) + public TextBlock WithIsVisible(bool value) { this.IsVisible = value; return this; } - public Container WithSeparator(bool value) + public TextBlock WithSeparator(bool value) { this.Separator = value; return this; } - public Container WithHeight(ElementHeight value) + public TextBlock WithHeight(ElementHeight value) { this.Height = value; return this; } - public Container WithHorizontalAlignment(HorizontalAlignment value) + public TextBlock WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public Container WithSpacing(Spacing value) + public TextBlock WithSpacing(Spacing value) { this.Spacing = value; return this; } - public Container WithTargetWidth(TargetWidth value) + public TextBlock WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public Container WithIsSortKey(bool value) + public TextBlock WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public Container WithSelectAction(Action value) - { - this.SelectAction = value; - return this; - } - - public Container WithStyle(ContainerStyle value) + public TextBlock WithText(string value) { - this.Style = value; + this.Text = value; return this; } - public Container WithShowBorder(bool value) + public TextBlock WithSize(TextSize value) { - this.ShowBorder = value; + this.Size = value; return this; } - public Container WithRoundedCorners(bool value) + public TextBlock WithWeight(TextWeight value) { - this.RoundedCorners = value; + this.Weight = value; return this; } - public Container WithLayouts(params IList value) + public TextBlock WithColor(TextColor value) { - this.Layouts = value; + this.Color = value; return this; } - public Container WithBleed(bool value) + public TextBlock WithIsSubtle(bool value) { - this.Bleed = value; + this.IsSubtle = value; return this; } - public Container WithMinHeight(string value) + public TextBlock WithFontType(FontType value) { - this.MinHeight = value; + this.FontType = value; return this; } - public Container WithBackgroundImage(IUnion value) + public TextBlock WithWrap(bool value) { - this.BackgroundImage = value; + this.Wrap = value; return this; } - public Container WithVerticalContentAlignment(VerticalAlignment value) + public TextBlock WithMaxLines(float value) { - this.VerticalContentAlignment = value; + this.MaxLines = value; return this; } - public Container WithRtl(bool value) + public TextBlock WithStyle(TextBlockStyle value) { - this.Rtl = value; + this.Style = value; return this; } - public Container WithMaxHeight(string value) + public TextBlock WithLabelFor(string value) { - this.MaxHeight = value; + this.LabelFor = value; return this; } - public Container WithGridArea(string value) + public TextBlock WithGridArea(string value) { this.GridArea = value; return this; } - public Container WithFallback(IUnion value) + public TextBlock WithFallback(IUnion value) { this.Fallback = value; return this; } - - public Container WithItems(params IList value) - { - this.Items = value; - return this; - } } /// -/// Displays a set of action, which can be placed anywhere in the card. +/// A set of facts, displayed as a table or a vertical list when horizontal space is constrained. /// -public class ActionSet : CardElement +public class FactSet : CardElement { /// - /// Deserializes a JSON string into an object of type ActionSet. + /// Deserializes a JSON string into an object of type FactSet. /// - public static ActionSet? Deserialize(string json) + public static FactSet? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **ActionSet**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **FactSet**. /// [JsonPropertyName("type")] - public string Type { get; } = "ActionSet"; + public string Type { get; } = "FactSet"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -4264,7 +6499,7 @@ public class ActionSet : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -4276,31 +6511,25 @@ public class ActionSet : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls how the element should be horizontally aligned. - /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -4312,7 +6541,13 @@ public class ActionSet : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; + + /// + /// The facts in the set. + /// + [JsonPropertyName("facts")] + public IList? Facts { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -4326,19 +6561,20 @@ public class ActionSet : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - /// - /// The actions in the set. - /// - [JsonPropertyName("actions")] - public IList? Actions { get; set; } + public FactSet() { } - public ActionSet(params IList actions) + public FactSet(params Fact[] facts) { - this.Actions = actions; + this.Facts = new List(facts); + } + + public FactSet(IList facts) + { + this.Facts = facts; } /// - /// Serializes this ActionSet into a JSON string. + /// Serializes this FactSet into a JSON string. /// public string Serialize() { @@ -4352,103 +6588,188 @@ public string Serialize() ); } - public ActionSet WithId(string value) + public FactSet WithKey(string value) + { + this.Key = value; + return this; + } + + public FactSet WithId(string value) { this.Id = value; return this; } - public ActionSet WithRequires(HostCapabilities value) + public FactSet WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ActionSet WithLang(string value) + public FactSet WithLang(string value) { this.Lang = value; return this; } - public ActionSet WithIsVisible(bool value) + public FactSet WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ActionSet WithSeparator(bool value) + public FactSet WithSeparator(bool value) { this.Separator = value; return this; } - public ActionSet WithHeight(ElementHeight value) + public FactSet WithHeight(ElementHeight value) { this.Height = value; return this; } - public ActionSet WithHorizontalAlignment(HorizontalAlignment value) + public FactSet WithSpacing(Spacing value) { - this.HorizontalAlignment = value; + this.Spacing = value; return this; } - public ActionSet WithSpacing(Spacing value) + public FactSet WithTargetWidth(TargetWidth value) { - this.Spacing = value; + this.TargetWidth = value; return this; } - public ActionSet WithTargetWidth(TargetWidth value) + public FactSet WithIsSortKey(bool value) { - this.TargetWidth = value; + this.IsSortKey = value; return this; } - public ActionSet WithIsSortKey(bool value) + public FactSet WithFacts(params Fact[] value) { - this.IsSortKey = value; + this.Facts = new List(value); return this; } - public ActionSet WithGridArea(string value) + public FactSet WithFacts(IList value) + { + this.Facts = value; + return this; + } + + public FactSet WithGridArea(string value) { this.GridArea = value; return this; } - public ActionSet WithFallback(IUnion value) + public FactSet WithFallback(IUnion value) { this.Fallback = value; return this; } +} + +/// +/// A fact in a FactSet element. +/// +public class Fact : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type Fact. + /// + public static Fact? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The fact's title. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The fact's value. + /// + [JsonPropertyName("value")] + public string? Value { get; set; } + + public Fact() { } - public ActionSet WithActions(params IList value) + public Fact(string title, string value) { - this.Actions = value; + this.Title = title; + this.Value = value; + } + + /// + /// Serializes this Fact into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public Fact WithKey(string value) + { + this.Key = value; + return this; + } + + public Fact WithTitle(string value) + { + this.Title = value; + return this; + } + + public Fact WithValue(string value) + { + this.Value = value; return this; } } /// -/// Splits the available horizontal space into separate columns, so elements can be organized in a row. +/// A set of images, displayed side-by-side and wrapped across multiple rows as needed. /// -public class ColumnSet : CardElement +public class ImageSet : CardElement { /// - /// Deserializes a JSON string into an object of type ColumnSet. + /// Deserializes a JSON string into an object of type ImageSet. /// - public static ColumnSet? Deserialize(string json) + public static ImageSet? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **ColumnSet**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **ImageSet**. /// [JsonPropertyName("type")] - public string Type { get; } = "ColumnSet"; + public string Type { get; } = "ImageSet"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -4460,7 +6781,7 @@ public class ColumnSet : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -4472,19 +6793,19 @@ public class ColumnSet : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -4496,7 +6817,7 @@ public class ColumnSet : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -4507,44 +6828,20 @@ public class ColumnSet : CardElement /// /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. - /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } - - /// - /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. - /// - [JsonPropertyName("style")] - public ContainerStyle? Style { get; set; } - - /// - /// Controls if a border should be displayed around the container. - /// - [JsonPropertyName("showBorder")] - public bool? ShowBorder { get; set; } - - /// - /// Controls if the container should have rounded corners. - /// - [JsonPropertyName("roundedCorners")] - public bool? RoundedCorners { get; set; } + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; /// - /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. + /// The images in the set. /// - [JsonPropertyName("bleed")] - public bool? Bleed { get; set; } + [JsonPropertyName("images")] + public IList? Images { get; set; } /// - /// The minimum height, in pixels, of the container, in the `px` format. + /// The size to use to render all images in the set. /// - [JsonPropertyName("minHeight")] - public string? MinHeight { get; set; } + [JsonPropertyName("imageSize")] + public ImageSize? ImageSize { get; set; } = ImageSize.Medium; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -4558,14 +6855,20 @@ public class ColumnSet : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - /// - /// The columns in the set. - /// - [JsonPropertyName("columns")] - public IList? Columns { get; set; } + public ImageSet() { } + + public ImageSet(params Image[] images) + { + this.Images = new List(images); + } + + public ImageSet(IList images) + { + this.Images = images; + } /// - /// Serializes this ColumnSet into a JSON string. + /// Serializes this ImageSet into a JSON string. /// public string Serialize() { @@ -4579,139 +6882,127 @@ public string Serialize() ); } - public ColumnSet WithId(string value) + public ImageSet WithKey(string value) + { + this.Key = value; + return this; + } + + public ImageSet WithId(string value) { this.Id = value; return this; } - public ColumnSet WithRequires(HostCapabilities value) + public ImageSet WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ColumnSet WithLang(string value) + public ImageSet WithLang(string value) { this.Lang = value; return this; } - public ColumnSet WithIsVisible(bool value) + public ImageSet WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ColumnSet WithSeparator(bool value) + public ImageSet WithSeparator(bool value) { this.Separator = value; return this; } - public ColumnSet WithHeight(ElementHeight value) + public ImageSet WithHeight(ElementHeight value) { this.Height = value; return this; } - public ColumnSet WithHorizontalAlignment(HorizontalAlignment value) + public ImageSet WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public ColumnSet WithSpacing(Spacing value) + public ImageSet WithSpacing(Spacing value) { this.Spacing = value; return this; } - public ColumnSet WithTargetWidth(TargetWidth value) + public ImageSet WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public ColumnSet WithIsSortKey(bool value) + public ImageSet WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public ColumnSet WithSelectAction(Action value) - { - this.SelectAction = value; - return this; - } - - public ColumnSet WithStyle(ContainerStyle value) - { - this.Style = value; - return this; - } - - public ColumnSet WithShowBorder(bool value) - { - this.ShowBorder = value; - return this; - } - - public ColumnSet WithRoundedCorners(bool value) + public ImageSet WithImages(params Image[] value) { - this.RoundedCorners = value; + this.Images = new List(value); return this; } - public ColumnSet WithBleed(bool value) + public ImageSet WithImages(IList value) { - this.Bleed = value; + this.Images = value; return this; } - public ColumnSet WithMinHeight(string value) + public ImageSet WithImageSize(ImageSize value) { - this.MinHeight = value; + this.ImageSize = value; return this; } - public ColumnSet WithGridArea(string value) + public ImageSet WithGridArea(string value) { this.GridArea = value; return this; } - public ColumnSet WithFallback(IUnion value) + public ImageSet WithFallback(IUnion value) { this.Fallback = value; return this; } - - public ColumnSet WithColumns(params IList value) - { - this.Columns = value; - return this; - } } /// -/// A media element, that makes it possible to embed videos inside a card. +/// A standalone image element. /// -public class Media : CardElement +public class Image : CardElement { /// - /// Deserializes a JSON string into an object of type Media. + /// Deserializes a JSON string into an object of type Image. /// - public static Media? Deserialize(string json) + public static Image? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Media**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Image**. /// [JsonPropertyName("type")] - public string Type { get; } = "Media"; + public string Type { get; } = "Image"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -4723,7 +7014,7 @@ public class Media : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -4735,25 +7026,25 @@ public class Media : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// Controls how the element should be horizontally aligned. /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -4765,31 +7056,91 @@ public class Media : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The sources for the media. For YouTube, Dailymotion and Vimeo, only one source can be specified. + /// The URL (or Base64-encoded Data URI) of the image. Acceptable formats are PNG, JPEG, GIF and SVG. /// - [JsonPropertyName("sources")] - public IList? Sources { get; set; } + [JsonPropertyName("url")] + public string? Url { get; set; } /// - /// The caption sources for the media. Caption sources are not used for YouTube, Dailymotion or Vimeo sources. + /// The alternate text for the image, used for accessibility purposes. /// - [JsonPropertyName("captionSources")] - public IList? CaptionSources { get; set; } + [JsonPropertyName("altText")] + public string? AltText { get; set; } /// - /// The URL of the poster image to display. + /// The background color of the image. /// - [JsonPropertyName("poster")] - public string? Poster { get; set; } + [JsonPropertyName("backgroundColor")] + public string? BackgroundColor { get; set; } /// - /// The alternate text for the media, used for accessibility purposes. + /// The style of the image. /// - [JsonPropertyName("altText")] - public string? AltText { get; set; } + [JsonPropertyName("style")] + public ImageStyle? Style { get; set; } = ImageStyle.Default; + + /// + /// The size of the image. + /// + [JsonPropertyName("size")] + public Size? Size { get; set; } = Size.Auto; + + /// + /// The width of the image. + /// + [JsonPropertyName("width")] + public string? Width { get; set; } = "auto"; + + /// + /// An Action that will be invoked when the image is tapped or clicked. Action.ShowCard is not supported. + /// + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } + + /// + /// Controls if the image can be expanded to full screen. + /// + [JsonPropertyName("allowExpand")] + public bool? AllowExpand { get; set; } = false; + + /// + /// Teams-specific metadata associated with the image. + /// + [JsonPropertyName("msteams")] + public TeamsImageProperties? Msteams { get; set; } + + /// + /// A set of theme-specific image URLs. + /// + [JsonPropertyName("themedUrls")] + public IList? ThemedUrls { get; set; } + + /// + /// Controls how the image should be fitted inside its bounding box. imageFit is only meaningful when both the width and height properties are set. When fitMode is set to contain, the default style is always used. + /// + [JsonPropertyName("fitMode")] + public ImageFitMode? FitMode { get; set; } = ImageFitMode.Fill; + + /// + /// Controls the horizontal position of the image within its bounding box. horizontalContentAlignment is only meaningful when both the width and height properties are set and fitMode is set to either cover or contain. + /// + [JsonPropertyName("horizontalContentAlignment")] + public HorizontalAlignment? HorizontalContentAlignment { get; set; } = HorizontalAlignment.Left; + + /// + /// Controls the vertical position of the image within its bounding box. verticalContentAlignment is only meaningful when both the width and height properties are set and fitMode is set to either cover or contain. + /// + [JsonPropertyName("verticalContentAlignment")] + public VerticalAlignment? VerticalContentAlignment { get; set; } = VerticalAlignment.Top; + + /// + /// The height of the image. + /// + [JsonPropertyName("height")] + public string? Height { get; set; } = "auto"; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -4803,8 +7154,15 @@ public class Media : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } + public Image() { } + + public Image(string url) + { + this.Url = url; + } + /// - /// Serializes this Media into a JSON string. + /// Serializes this Image into a JSON string. /// public string Serialize() { @@ -4818,183 +7176,196 @@ public string Serialize() ); } - public Media WithId(string value) + public Image WithKey(string value) + { + this.Key = value; + return this; + } + + public Image WithId(string value) { this.Id = value; return this; } - public Media WithRequires(HostCapabilities value) + public Image WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public Media WithLang(string value) + public Image WithLang(string value) { this.Lang = value; return this; } - public Media WithIsVisible(bool value) + public Image WithIsVisible(bool value) { this.IsVisible = value; return this; } - public Media WithSeparator(bool value) + public Image WithSeparator(bool value) { this.Separator = value; return this; } - public Media WithHeight(ElementHeight value) + public Image WithHorizontalAlignment(HorizontalAlignment value) { - this.Height = value; + this.HorizontalAlignment = value; return this; } - public Media WithSpacing(Spacing value) + public Image WithSpacing(Spacing value) { this.Spacing = value; return this; } - public Media WithTargetWidth(TargetWidth value) + public Image WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public Media WithIsSortKey(bool value) + public Image WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public Media WithSources(params IList value) + public Image WithUrl(string value) { - this.Sources = value; + this.Url = value; + return this; + } + + public Image WithAltText(string value) + { + this.AltText = value; + return this; + } + + public Image WithBackgroundColor(string value) + { + this.BackgroundColor = value; + return this; + } + + public Image WithStyle(ImageStyle value) + { + this.Style = value; + return this; + } + + public Image WithSize(Size value) + { + this.Size = value; + return this; + } + + public Image WithWidth(string value) + { + this.Width = value; + return this; + } + + public Image WithSelectAction(Action value) + { + this.SelectAction = value; + return this; + } + + public Image WithAllowExpand(bool value) + { + this.AllowExpand = value; return this; } - public Media WithCaptionSources(params IList value) + public Image WithMsteams(TeamsImageProperties value) { - this.CaptionSources = value; + this.Msteams = value; return this; } - public Media WithPoster(string value) + public Image WithThemedUrls(params ThemedUrl[] value) { - this.Poster = value; + this.ThemedUrls = new List(value); return this; } - public Media WithAltText(string value) + public Image WithThemedUrls(IList value) { - this.AltText = value; + this.ThemedUrls = value; return this; } - public Media WithGridArea(string value) + public Image WithFitMode(ImageFitMode value) { - this.GridArea = value; + this.FitMode = value; return this; } - public Media WithFallback(IUnion value) + public Image WithHorizontalContentAlignment(HorizontalAlignment value) { - this.Fallback = value; + this.HorizontalContentAlignment = value; return this; } -} -/// -/// Defines the source URL of a media stream. YouTube, Dailymotion, Vimeo and Microsoft Stream URLs are supported. -/// -public class MediaSource : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type MediaSource. - /// - public static MediaSource? Deserialize(string json) + public Image WithVerticalContentAlignment(VerticalAlignment value) { - return JsonSerializer.Deserialize(json); + this.VerticalContentAlignment = value; + return this; } - /// - /// The MIME type of the source. - /// - [JsonPropertyName("mimeType")] - public string? MimeType { get; set; } - - /// - /// The URL of the source. - /// - [JsonPropertyName("url")] - public string? Url { get; set; } - - /// - /// Serializes this MediaSource into a JSON string. - /// - public string Serialize() + public Image WithHeight(string value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Height = value; + return this; } - public MediaSource WithMimeType(string value) + public Image WithGridArea(string value) { - this.MimeType = value; + this.GridArea = value; return this; } - public MediaSource WithUrl(string value) + public Image WithFallback(IUnion value) { - this.Url = value; + this.Fallback = value; return this; } } /// -/// Defines a source URL for a video captions. +/// Represents a set of Teams-specific properties on an image. /// -public class CaptionSource : SerializableObject +public class TeamsImageProperties : SerializableObject { /// - /// Deserializes a JSON string into an object of type CaptionSource. + /// Deserializes a JSON string into an object of type TeamsImageProperties. /// - public static CaptionSource? Deserialize(string json) + public static TeamsImageProperties? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The MIME type of the source. - /// - [JsonPropertyName("mimeType")] - public string? MimeType { get; set; } - - /// - /// The URL of the source. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The label of this caption source. + /// Controls if the image is expandable in Teams. This property is equivalent to the Image.allowExpand property. /// - [JsonPropertyName("label")] - public string? Label { get; set; } + [JsonPropertyName("allowExpand")] + public bool? AllowExpand { get; set; } /// - /// Serializes this CaptionSource into a JSON string. + /// Serializes this TeamsImageProperties into a JSON string. /// public string Serialize() { @@ -5008,43 +7379,43 @@ public string Serialize() ); } - public CaptionSource WithMimeType(string value) - { - this.MimeType = value; - return this; - } - - public CaptionSource WithUrl(string value) + public TeamsImageProperties WithKey(string value) { - this.Url = value; + this.Key = value; return this; } - public CaptionSource WithLabel(string value) + public TeamsImageProperties WithAllowExpand(bool value) { - this.Label = value; + this.AllowExpand = value; return this; } } /// -/// A rich text block that displays formatted text. +/// An input to allow the user to enter text. /// -public class RichTextBlock : CardElement +public class TextInput : CardElement { /// - /// Deserializes a JSON string into an object of type RichTextBlock. + /// Deserializes a JSON string into an object of type TextInput. /// - public static RichTextBlock? Deserialize(string json) + public static TextInput? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **RichTextBlock**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Input.Text**. /// [JsonPropertyName("type")] - public string Type { get; } = "RichTextBlock"; + public string Type { get; } = "Input.Text"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -5056,7 +7427,7 @@ public class RichTextBlock : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -5068,31 +7439,25 @@ public class RichTextBlock : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls how the element should be horizontally aligned. - /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -5104,7 +7469,75 @@ public class RichTextBlock : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; + + /// + /// The label of the input. + /// + /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. + /// + [JsonPropertyName("label")] + public string? Label { get; set; } + + /// + /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// + [JsonPropertyName("isRequired")] + public bool? IsRequired { get; set; } = false; + + /// + /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } + + /// + /// An Action.ResetInputs action that will be executed when the value of the input changes. + /// + [JsonPropertyName("valueChangedAction")] + public Action? ValueChangedAction { get; set; } + + /// + /// The default value of the input. + /// + [JsonPropertyName("value")] + public string? Value { get; set; } + + /// + /// The maximum length of the text in the input. + /// + [JsonPropertyName("maxLength")] + public float? MaxLength { get; set; } + + /// + /// Controls if the input should allow multiple lines of text. + /// + [JsonPropertyName("isMultiline")] + public bool? IsMultiline { get; set; } = false; + + /// + /// The text to display as a placeholder when the user hasn't entered a value. + /// + [JsonPropertyName("placeholder")] + public string? Placeholder { get; set; } + + /// + /// The style of the input. + /// + [JsonPropertyName("style")] + public InputTextStyle? Style { get; set; } = InputTextStyle.Text; + + /// + /// The action that should be displayed as a button alongside the input. Action.ShowCard is not supported. + /// + [JsonPropertyName("inlineAction")] + public Action? InlineAction { get; set; } + + /// + /// The regular expression to validate the input. + /// + [JsonPropertyName("regex")] + public string? Regex { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -5119,123 +7552,183 @@ public class RichTextBlock : CardElement public IUnion? Fallback { get; set; } /// - /// The inlines making up the rich text block. - /// - [JsonPropertyName("inlines")] - public IUnion, IList>? Inlines { get; set; } - - /// - /// Serializes this RichTextBlock into a JSON string. + /// Serializes this TextInput into a JSON string. /// public string Serialize() { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public TextInput WithKey(string value) + { + this.Key = value; + return this; + } + + public TextInput WithId(string value) + { + this.Id = value; + return this; + } + + public TextInput WithRequires(HostCapabilities value) + { + this.Requires = value; + return this; + } + + public TextInput WithLang(string value) + { + this.Lang = value; + return this; + } + + public TextInput WithIsVisible(bool value) + { + this.IsVisible = value; + return this; + } + + public TextInput WithSeparator(bool value) + { + this.Separator = value; + return this; + } + + public TextInput WithHeight(ElementHeight value) + { + this.Height = value; + return this; + } + + public TextInput WithSpacing(Spacing value) + { + this.Spacing = value; + return this; + } + + public TextInput WithTargetWidth(TargetWidth value) + { + this.TargetWidth = value; + return this; + } + + public TextInput WithIsSortKey(bool value) + { + this.IsSortKey = value; + return this; } - public RichTextBlock WithId(string value) + public TextInput WithLabel(string value) { - this.Id = value; + this.Label = value; return this; } - public RichTextBlock WithRequires(HostCapabilities value) + public TextInput WithIsRequired(bool value) { - this.Requires = value; + this.IsRequired = value; return this; } - public RichTextBlock WithLang(string value) + public TextInput WithErrorMessage(string value) { - this.Lang = value; + this.ErrorMessage = value; return this; } - public RichTextBlock WithIsVisible(bool value) + public TextInput WithValueChangedAction(Action value) { - this.IsVisible = value; + this.ValueChangedAction = value; return this; } - public RichTextBlock WithSeparator(bool value) + public TextInput WithValue(string value) { - this.Separator = value; + this.Value = value; return this; } - public RichTextBlock WithHeight(ElementHeight value) + public TextInput WithMaxLength(float value) { - this.Height = value; + this.MaxLength = value; return this; } - public RichTextBlock WithHorizontalAlignment(HorizontalAlignment value) + public TextInput WithIsMultiline(bool value) { - this.HorizontalAlignment = value; + this.IsMultiline = value; return this; } - public RichTextBlock WithSpacing(Spacing value) + public TextInput WithPlaceholder(string value) { - this.Spacing = value; + this.Placeholder = value; return this; } - public RichTextBlock WithTargetWidth(TargetWidth value) + public TextInput WithStyle(InputTextStyle value) { - this.TargetWidth = value; + this.Style = value; return this; } - public RichTextBlock WithIsSortKey(bool value) + public TextInput WithInlineAction(Action value) { - this.IsSortKey = value; + this.InlineAction = value; return this; } - public RichTextBlock WithGridArea(string value) + public TextInput WithRegex(string value) { - this.GridArea = value; + this.Regex = value; return this; } - public RichTextBlock WithFallback(IUnion value) + public TextInput WithGridArea(string value) { - this.Fallback = value; + this.GridArea = value; return this; } - public RichTextBlock WithInlines(IUnion, IList> value) + public TextInput WithFallback(IUnion value) { - this.Inlines = value; + this.Fallback = value; return this; } } /// -/// Use tables to display data in a tabular way, with rows, columns and cells. +/// An input to allow the user to select a date. /// -public class Table : CardElement +public class DateInput : CardElement { /// - /// Deserializes a JSON string into an object of type Table. + /// Deserializes a JSON string into an object of type DateInput. /// - public static Table? Deserialize(string json) + public static DateInput? Deserialize(string json) { - return JsonSerializer.Deserialize
(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Table**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Input.Date**. /// [JsonPropertyName("type")] - public string Type { get; } = "Table"; + public string Type { get; } = "Input.Date"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -5247,7 +7740,7 @@ public class Table : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -5259,31 +7752,25 @@ public class Table : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls how the element should be horizontally aligned. - /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -5295,61 +7782,57 @@ public class Table : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. - /// - [JsonPropertyName("style")] - public ContainerStyle? Style { get; set; } - - /// - /// Controls if a border should be displayed around the container. + /// The label of the input. + /// + /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. /// - [JsonPropertyName("showBorder")] - public bool? ShowBorder { get; set; } + [JsonPropertyName("label")] + public string? Label { get; set; } /// - /// Controls if the container should have rounded corners. + /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. /// - [JsonPropertyName("roundedCorners")] - public bool? RoundedCorners { get; set; } + [JsonPropertyName("isRequired")] + public bool? IsRequired { get; set; } = false; /// - /// The columns in the table. + /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. /// - [JsonPropertyName("columns")] - public IList? Columns { get; set; } + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } /// - /// Controls whether the first row of the table should be treated as a header. + /// An Action.ResetInputs action that will be executed when the value of the input changes. /// - [JsonPropertyName("firstRowAsHeaders")] - public bool? FirstRowAsHeaders { get; set; } + [JsonPropertyName("valueChangedAction")] + public Action? ValueChangedAction { get; set; } /// - /// Controls if grid lines should be displayed. + /// The default value of the input, in the `YYYY-MM-DD` format. /// - [JsonPropertyName("showGridLines")] - public bool? ShowGridLines { get; set; } + [JsonPropertyName("value")] + public string? Value { get; set; } /// - /// The style of the grid lines between cells. + /// The text to display as a placeholder when the user has not selected a date. /// - [JsonPropertyName("gridStyle")] - public ContainerStyle? GridStyle { get; set; } + [JsonPropertyName("placeholder")] + public string? Placeholder { get; set; } /// - /// Controls how the content of every cell in the table should be horizontally aligned by default. + /// The minimum date that can be selected. /// - [JsonPropertyName("horizontalCellContentAlignment")] - public HorizontalAlignment? HorizontalCellContentAlignment { get; set; } + [JsonPropertyName("min")] + public string? Min { get; set; } /// - /// Controls how the content of every cell in the table should be vertically aligned by default. + /// The maximum date that can be selected. /// - [JsonPropertyName("verticalCellContentAlignment")] - public VerticalAlignment? VerticalCellContentAlignment { get; set; } + [JsonPropertyName("max")] + public string? Max { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -5364,13 +7847,7 @@ public class Table : CardElement public IUnion? Fallback { get; set; } /// - /// The rows of the table. - /// - [JsonPropertyName("rows")] - public IList? Rows { get; set; } - - /// - /// Serializes this Table into a JSON string. + /// Serializes this DateInput into a JSON string. /// public string Serialize() { @@ -5384,222 +7861,151 @@ public string Serialize() ); } - public Table WithId(string value) + public DateInput WithKey(string value) + { + this.Key = value; + return this; + } + + public DateInput WithId(string value) { this.Id = value; return this; } - public Table WithRequires(HostCapabilities value) + public DateInput WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public Table WithLang(string value) + public DateInput WithLang(string value) { this.Lang = value; return this; } - public Table WithIsVisible(bool value) + public DateInput WithIsVisible(bool value) { this.IsVisible = value; return this; } - public Table WithSeparator(bool value) + public DateInput WithSeparator(bool value) { this.Separator = value; return this; } - public Table WithHeight(ElementHeight value) + public DateInput WithHeight(ElementHeight value) { this.Height = value; return this; } - public Table WithHorizontalAlignment(HorizontalAlignment value) - { - this.HorizontalAlignment = value; - return this; - } - - public Table WithSpacing(Spacing value) + public DateInput WithSpacing(Spacing value) { this.Spacing = value; return this; } - public Table WithTargetWidth(TargetWidth value) + public DateInput WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public Table WithIsSortKey(bool value) + public DateInput WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public Table WithStyle(ContainerStyle value) - { - this.Style = value; - return this; - } - - public Table WithShowBorder(bool value) - { - this.ShowBorder = value; - return this; - } - - public Table WithRoundedCorners(bool value) - { - this.RoundedCorners = value; - return this; - } - - public Table WithColumns(params IList value) - { - this.Columns = value; - return this; - } - - public Table WithFirstRowAsHeaders(bool value) - { - this.FirstRowAsHeaders = value; - return this; - } - - public Table WithShowGridLines(bool value) - { - this.ShowGridLines = value; - return this; - } - - public Table WithGridStyle(ContainerStyle value) - { - this.GridStyle = value; - return this; - } - - public Table WithHorizontalCellContentAlignment(HorizontalAlignment value) - { - this.HorizontalCellContentAlignment = value; - return this; - } - - public Table WithVerticalCellContentAlignment(VerticalAlignment value) + public DateInput WithLabel(string value) { - this.VerticalCellContentAlignment = value; + this.Label = value; return this; } - public Table WithGridArea(string value) + public DateInput WithIsRequired(bool value) { - this.GridArea = value; + this.IsRequired = value; return this; } - public Table WithFallback(IUnion value) + public DateInput WithErrorMessage(string value) { - this.Fallback = value; + this.ErrorMessage = value; return this; } - public Table WithRows(params IList value) + public DateInput WithValueChangedAction(Action value) { - this.Rows = value; + this.ValueChangedAction = value; return this; - } -} - -/// -/// Defines a column in a Table element. -/// -public class ColumnDefinition : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type ColumnDefinition. - /// - public static ColumnDefinition? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } - - /// - /// Controls how the content of every cell in the table should be horizontally aligned by default. This property overrides the horizontalCellContentAlignment property of the table. - /// - [JsonPropertyName("horizontalCellContentAlignment")] - public HorizontalAlignment? HorizontalCellContentAlignment { get; set; } + } - /// - /// Controls how the content of every cell in the column should be vertically aligned by default. This property overrides the verticalCellContentAlignment property of the table. - /// - [JsonPropertyName("verticalCellContentAlignment")] - public VerticalAlignment? VerticalCellContentAlignment { get; set; } + public DateInput WithValue(string value) + { + this.Value = value; + return this; + } - /// - /// The width of the column in the table, expressed as either a percentage of the available width or in pixels, using the `px` format. - /// - [JsonPropertyName("width")] - public IUnion? Width { get; set; } + public DateInput WithPlaceholder(string value) + { + this.Placeholder = value; + return this; + } - /// - /// Serializes this ColumnDefinition into a JSON string. - /// - public string Serialize() + public DateInput WithMin(string value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Min = value; + return this; } - public ColumnDefinition WithHorizontalCellContentAlignment(HorizontalAlignment value) + public DateInput WithMax(string value) { - this.HorizontalCellContentAlignment = value; + this.Max = value; return this; } - public ColumnDefinition WithVerticalCellContentAlignment(VerticalAlignment value) + public DateInput WithGridArea(string value) { - this.VerticalCellContentAlignment = value; + this.GridArea = value; return this; } - public ColumnDefinition WithWidth(IUnion value) + public DateInput WithFallback(IUnion value) { - this.Width = value; + this.Fallback = value; return this; } } /// -/// A block of text, optionally formatted using Markdown. +/// An input to allow the user to select a time. /// -public class TextBlock : CardElement +public class TimeInput : CardElement { /// - /// Deserializes a JSON string into an object of type TextBlock. + /// Deserializes a JSON string into an object of type TimeInput. /// - public static TextBlock? Deserialize(string json) + public static TimeInput? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **TextBlock**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Input.Time**. /// [JsonPropertyName("type")] - public string Type { get; } = "TextBlock"; + public string Type { get; } = "Input.Time"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -5611,7 +8017,7 @@ public class TextBlock : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -5623,31 +8029,25 @@ public class TextBlock : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls how the element should be horizontally aligned. - /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -5659,61 +8059,57 @@ public class TextBlock : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The text to display. A subset of markdown is supported. - /// - [JsonPropertyName("text")] - public string? Text { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The size of the text. + /// The label of the input. + /// + /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. /// - [JsonPropertyName("size")] - public TextSize? Size { get; set; } + [JsonPropertyName("label")] + public string? Label { get; set; } /// - /// The weight of the text. + /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. /// - [JsonPropertyName("weight")] - public TextWeight? Weight { get; set; } + [JsonPropertyName("isRequired")] + public bool? IsRequired { get; set; } = false; /// - /// The color of the text. + /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. /// - [JsonPropertyName("color")] - public TextColor? Color { get; set; } + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } /// - /// Controls whether the text should be renderer using a subtler variant of the select color. + /// An Action.ResetInputs action that will be executed when the value of the input changes. /// - [JsonPropertyName("isSubtle")] - public bool? IsSubtle { get; set; } + [JsonPropertyName("valueChangedAction")] + public Action? ValueChangedAction { get; set; } /// - /// The type of font to use for rendering. + /// The default value of the input, in the `HH:MM` format. /// - [JsonPropertyName("fontType")] - public FontType? FontType { get; set; } + [JsonPropertyName("value")] + public string? Value { get; set; } /// - /// Controls if the text should wrap. + /// The text to display as a placeholder when the user hasn't entered a value. /// - [JsonPropertyName("wrap")] - public bool? Wrap { get; set; } + [JsonPropertyName("placeholder")] + public string? Placeholder { get; set; } /// - /// The maximum number of lines to display. + /// The minimum time that can be selected, in the `HH:MM` format. /// - [JsonPropertyName("maxLines")] - public float? MaxLines { get; set; } + [JsonPropertyName("min")] + public string? Min { get; set; } /// - /// The style of the text. + /// The maximum time that can be selected, in the `HH:MM` format. /// - [JsonPropertyName("style")] - public StyleEnum? Style { get; set; } + [JsonPropertyName("max")] + public string? Max { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -5727,13 +8123,8 @@ public class TextBlock : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - public TextBlock(string text) - { - this.Text = text; - } - /// - /// Serializes this TextBlock into a JSON string. + /// Serializes this TimeInput into a JSON string. /// public string Serialize() { @@ -5747,127 +8138,121 @@ public string Serialize() ); } - public TextBlock WithId(string value) + public TimeInput WithKey(string value) + { + this.Key = value; + return this; + } + + public TimeInput WithId(string value) { this.Id = value; return this; } - public TextBlock WithRequires(HostCapabilities value) + public TimeInput WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public TextBlock WithLang(string value) + public TimeInput WithLang(string value) { this.Lang = value; return this; } - public TextBlock WithIsVisible(bool value) + public TimeInput WithIsVisible(bool value) { this.IsVisible = value; return this; } - public TextBlock WithSeparator(bool value) + public TimeInput WithSeparator(bool value) { this.Separator = value; return this; } - public TextBlock WithHeight(ElementHeight value) + public TimeInput WithHeight(ElementHeight value) { this.Height = value; return this; } - public TextBlock WithHorizontalAlignment(HorizontalAlignment value) - { - this.HorizontalAlignment = value; - return this; - } - - public TextBlock WithSpacing(Spacing value) + public TimeInput WithSpacing(Spacing value) { this.Spacing = value; return this; } - public TextBlock WithTargetWidth(TargetWidth value) + public TimeInput WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public TextBlock WithIsSortKey(bool value) + public TimeInput WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public TextBlock WithText(string value) - { - this.Text = value; - return this; - } - - public TextBlock WithSize(TextSize value) + public TimeInput WithLabel(string value) { - this.Size = value; + this.Label = value; return this; } - public TextBlock WithWeight(TextWeight value) + public TimeInput WithIsRequired(bool value) { - this.Weight = value; + this.IsRequired = value; return this; } - public TextBlock WithColor(TextColor value) + public TimeInput WithErrorMessage(string value) { - this.Color = value; + this.ErrorMessage = value; return this; } - public TextBlock WithIsSubtle(bool value) + public TimeInput WithValueChangedAction(Action value) { - this.IsSubtle = value; + this.ValueChangedAction = value; return this; } - public TextBlock WithFontType(FontType value) + public TimeInput WithValue(string value) { - this.FontType = value; + this.Value = value; return this; } - public TextBlock WithWrap(bool value) + public TimeInput WithPlaceholder(string value) { - this.Wrap = value; + this.Placeholder = value; return this; } - public TextBlock WithMaxLines(float value) + public TimeInput WithMin(string value) { - this.MaxLines = value; + this.Min = value; return this; } - public TextBlock WithStyle(StyleEnum value) + public TimeInput WithMax(string value) { - this.Style = value; + this.Max = value; return this; } - public TextBlock WithGridArea(string value) + public TimeInput WithGridArea(string value) { this.GridArea = value; return this; } - public TextBlock WithFallback(IUnion value) + public TimeInput WithFallback(IUnion value) { this.Fallback = value; return this; @@ -5875,23 +8260,29 @@ public TextBlock WithFallback(IUnion value) } /// -/// A set of facts, displayed as a table or a vertical list when horizontal space is constrained. +/// An input to allow the user to enter a number. /// -public class FactSet : CardElement +public class NumberInput : CardElement { /// - /// Deserializes a JSON string into an object of type FactSet. + /// Deserializes a JSON string into an object of type NumberInput. /// - public static FactSet? Deserialize(string json) + public static NumberInput? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **FactSet**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Input.Number**. /// [JsonPropertyName("type")] - public string Type { get; } = "FactSet"; + public string Type { get; } = "Input.Number"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -5903,7 +8294,7 @@ public class FactSet : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -5915,43 +8306,87 @@ public class FactSet : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; + + /// + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). + /// + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } + + /// + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; + + /// + /// The label of the input. + /// + /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. + /// + [JsonPropertyName("label")] + public string? Label { get; set; } + + /// + /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// + [JsonPropertyName("isRequired")] + public bool? IsRequired { get; set; } = false; + + /// + /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } + + /// + /// An Action.ResetInputs action that will be executed when the value of the input changes. + /// + [JsonPropertyName("valueChangedAction")] + public Action? ValueChangedAction { get; set; } + + /// + /// The default value of the input. + /// + [JsonPropertyName("value")] + public float? Value { get; set; } /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). + /// The text to display as a placeholder when the user hasn't entered a value. /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } + [JsonPropertyName("placeholder")] + public string? Placeholder { get; set; } /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// The minimum value that can be entered. /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + [JsonPropertyName("min")] + public float? Min { get; set; } /// - /// The facts in the set. + /// The maximum value that can be entered. /// - [JsonPropertyName("facts")] - public IList? Facts { get; set; } + [JsonPropertyName("max")] + public float? Max { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -5965,13 +8400,8 @@ public class FactSet : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - public FactSet(params IList facts) - { - this.Facts = facts; - } - /// - /// Serializes this FactSet into a JSON string. + /// Serializes this NumberInput into a JSON string. /// public string Serialize() { @@ -5985,156 +8415,151 @@ public string Serialize() ); } - public FactSet WithId(string value) + public NumberInput WithKey(string value) + { + this.Key = value; + return this; + } + + public NumberInput WithId(string value) { this.Id = value; return this; } - public FactSet WithRequires(HostCapabilities value) + public NumberInput WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public FactSet WithLang(string value) + public NumberInput WithLang(string value) { this.Lang = value; return this; } - public FactSet WithIsVisible(bool value) + public NumberInput WithIsVisible(bool value) { this.IsVisible = value; return this; } - public FactSet WithSeparator(bool value) + public NumberInput WithSeparator(bool value) { this.Separator = value; return this; } - public FactSet WithHeight(ElementHeight value) + public NumberInput WithHeight(ElementHeight value) { this.Height = value; return this; } - public FactSet WithSpacing(Spacing value) + public NumberInput WithSpacing(Spacing value) { this.Spacing = value; return this; } - public FactSet WithTargetWidth(TargetWidth value) + public NumberInput WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public FactSet WithIsSortKey(bool value) + public NumberInput WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public FactSet WithFacts(params IList value) + public NumberInput WithLabel(string value) { - this.Facts = value; + this.Label = value; return this; } - public FactSet WithGridArea(string value) + public NumberInput WithIsRequired(bool value) { - this.GridArea = value; + this.IsRequired = value; return this; } - public FactSet WithFallback(IUnion value) + public NumberInput WithErrorMessage(string value) { - this.Fallback = value; + this.ErrorMessage = value; return this; } -} -/// -/// A fact in a FactSet element. -/// -public class Fact : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type Fact. - /// - public static Fact? Deserialize(string json) + public NumberInput WithValueChangedAction(Action value) { - return JsonSerializer.Deserialize(json); + this.ValueChangedAction = value; + return this; } - /// - /// The fact's title. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } + public NumberInput WithValue(float value) + { + this.Value = value; + return this; + } - /// - /// The fact's value. - /// - [JsonPropertyName("value")] - public string? Value { get; set; } + public NumberInput WithPlaceholder(string value) + { + this.Placeholder = value; + return this; + } - public Fact(string title, string value) + public NumberInput WithMin(float value) { - this.Title = title; - this.Value = value; + this.Min = value; + return this; } - /// - /// Serializes this Fact into a JSON string. - /// - public string Serialize() + public NumberInput WithMax(float value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Max = value; + return this; } - public Fact WithTitle(string value) + public NumberInput WithGridArea(string value) { - this.Title = value; + this.GridArea = value; return this; } - public Fact WithValue(string value) + public NumberInput WithFallback(IUnion value) { - this.Value = value; + this.Fallback = value; return this; } } /// -/// A set of images, displayed side-by-side and wrapped across multiple rows as needed. +/// An input to allow the user to select between on/off states. /// -public class ImageSet : CardElement +public class ToggleInput : CardElement { /// - /// Deserializes a JSON string into an object of type ImageSet. + /// Deserializes a JSON string into an object of type ToggleInput. /// - public static ImageSet? Deserialize(string json) + public static ToggleInput? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **ImageSet**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Input.Toggle**. /// [JsonPropertyName("type")] - public string Type { get; } = "ImageSet"; + public string Type { get; } = "Input.Toggle"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -6146,7 +8571,7 @@ public class ImageSet : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -6158,31 +8583,25 @@ public class ImageSet : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls how the element should be horizontally aligned. - /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -6194,19 +8613,69 @@ public class ImageSet : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The images in the set. + /// The label of the input. + /// + /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. /// - [JsonPropertyName("images")] - public IList? Images { get; set; } + [JsonPropertyName("label")] + public string? Label { get; set; } /// - /// The size to use to render all images in the set. + /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. /// - [JsonPropertyName("imageSize")] - public ImageSize? ImageSize { get; set; } + [JsonPropertyName("isRequired")] + public bool? IsRequired { get; set; } = false; + + /// + /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } + + /// + /// An Action.ResetInputs action that will be executed when the value of the input changes. + /// + [JsonPropertyName("valueChangedAction")] + public Action? ValueChangedAction { get; set; } + + /// + /// The default value of the input. + /// + [JsonPropertyName("value")] + public string? Value { get; set; } = "false"; + + /// + /// The title (caption) to display next to the toggle. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The value to send to the Bot when the toggle is on. + /// + [JsonPropertyName("valueOn")] + public string? ValueOn { get; set; } = "true"; + + /// + /// The value to send to the Bot when the toggle is off. + /// + [JsonPropertyName("valueOff")] + public string? ValueOff { get; set; } = "false"; + + /// + /// Controls if the title should wrap. + /// + [JsonPropertyName("wrap")] + public bool? Wrap { get; set; } = true; + + /// + /// Controls whether the title is visually displayed. When set to false, the title is hidden from view but remains accessible to screen readers for accessibility purposes. + /// + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = true; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -6220,13 +8689,15 @@ public class ImageSet : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - public ImageSet(params IList images) + public ToggleInput() { } + + public ToggleInput(string title) { - this.Images = images; + this.Title = title; } /// - /// Serializes this ImageSet into a JSON string. + /// Serializes this ToggleInput into a JSON string. /// public string Serialize() { @@ -6240,85 +8711,133 @@ public string Serialize() ); } - public ImageSet WithId(string value) + public ToggleInput WithKey(string value) + { + this.Key = value; + return this; + } + + public ToggleInput WithId(string value) { this.Id = value; return this; } - public ImageSet WithRequires(HostCapabilities value) + public ToggleInput WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ImageSet WithLang(string value) + public ToggleInput WithLang(string value) { this.Lang = value; return this; } - public ImageSet WithIsVisible(bool value) + public ToggleInput WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ImageSet WithSeparator(bool value) + public ToggleInput WithSeparator(bool value) + { + this.Separator = value; + return this; + } + + public ToggleInput WithHeight(ElementHeight value) + { + this.Height = value; + return this; + } + + public ToggleInput WithSpacing(Spacing value) + { + this.Spacing = value; + return this; + } + + public ToggleInput WithTargetWidth(TargetWidth value) + { + this.TargetWidth = value; + return this; + } + + public ToggleInput WithIsSortKey(bool value) + { + this.IsSortKey = value; + return this; + } + + public ToggleInput WithLabel(string value) + { + this.Label = value; + return this; + } + + public ToggleInput WithIsRequired(bool value) + { + this.IsRequired = value; + return this; + } + + public ToggleInput WithErrorMessage(string value) { - this.Separator = value; + this.ErrorMessage = value; return this; } - public ImageSet WithHeight(ElementHeight value) + public ToggleInput WithValueChangedAction(Action value) { - this.Height = value; + this.ValueChangedAction = value; return this; } - public ImageSet WithHorizontalAlignment(HorizontalAlignment value) + public ToggleInput WithValue(string value) { - this.HorizontalAlignment = value; + this.Value = value; return this; } - public ImageSet WithSpacing(Spacing value) + public ToggleInput WithTitle(string value) { - this.Spacing = value; + this.Title = value; return this; } - public ImageSet WithTargetWidth(TargetWidth value) + public ToggleInput WithValueOn(string value) { - this.TargetWidth = value; + this.ValueOn = value; return this; } - public ImageSet WithIsSortKey(bool value) + public ToggleInput WithValueOff(string value) { - this.IsSortKey = value; + this.ValueOff = value; return this; } - public ImageSet WithImages(params IList value) + public ToggleInput WithWrap(bool value) { - this.Images = value; + this.Wrap = value; return this; } - public ImageSet WithImageSize(ImageSize value) + public ToggleInput WithShowTitle(bool value) { - this.ImageSize = value; + this.ShowTitle = value; return this; } - public ImageSet WithGridArea(string value) + public ToggleInput WithGridArea(string value) { this.GridArea = value; return this; } - public ImageSet WithFallback(IUnion value) + public ToggleInput WithFallback(IUnion value) { this.Fallback = value; return this; @@ -6326,23 +8845,29 @@ public ImageSet WithFallback(IUnion value) } /// -/// A standalone image element. +/// An input to allow the user to select one or more values. /// -public class Image : CardElement +public class ChoiceSetInput : CardElement { /// - /// Deserializes a JSON string into an object of type Image. + /// Deserializes a JSON string into an object of type ChoiceSetInput. /// - public static Image? Deserialize(string json) + public static ChoiceSetInput? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Image**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Input.ChoiceSet**. /// [JsonPropertyName("type")] - public string Type { get; } = "Image"; + public string Type { get; } = "Input.ChoiceSet"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -6354,7 +8879,7 @@ public class Image : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -6366,25 +8891,25 @@ public class Image : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// - /// Controls how the element should be horizontally aligned. + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -6396,64 +8921,87 @@ public class Image : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The URL (or Base64-encoded Data URI) of the image. Acceptable formats are PNG, JPEG, GIF and SVG. + /// The label of the input. + /// + /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. /// - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonPropertyName("label")] + public string? Label { get; set; } /// - /// The alternate text for the image, used for accessibility purposes. + /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. /// - [JsonPropertyName("altText")] - public string? AltText { get; set; } + [JsonPropertyName("isRequired")] + public bool? IsRequired { get; set; } = false; /// - /// The background color of the image. + /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. /// - [JsonPropertyName("backgroundColor")] - public string? BackgroundColor { get; set; } + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } /// - /// The style of the image. + /// An Action.ResetInputs action that will be executed when the value of the input changes. /// - [JsonPropertyName("style")] - public ImageStyle? Style { get; set; } + [JsonPropertyName("valueChangedAction")] + public Action? ValueChangedAction { get; set; } /// - /// The size of the image. + /// The default value of the input. /// - [JsonPropertyName("size")] - public Size? Size { get; set; } + [JsonPropertyName("value")] + public string? Value { get; set; } /// - /// The width of the image. + /// The choices associated with the input. /// - [JsonPropertyName("width")] - public string? Width { get; set; } + [JsonPropertyName("choices")] + public IList? Choices { get; set; } /// - /// An Action that will be invoked when the image is tapped or clicked. Action.ShowCard is not supported. + /// A Data.Query object that defines the dataset from which to dynamically fetch the choices for the input. /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } + [JsonPropertyName("choices.data")] + public QueryData? ChoicesData { get; set; } /// - /// Controls if the image can be expanded to full screen. + /// Controls whether the input should be displayed as a dropdown (compact) or a list of radio buttons or checkboxes (expanded). /// - [JsonPropertyName("allowExpand")] - public bool? AllowExpand { get; set; } + [JsonPropertyName("style")] + public ChoiceSetInputStyle? Style { get; set; } = ChoiceSetInputStyle.Compact; - [JsonPropertyName("msteams")] - public TeamsImageProperties? Msteams { get; set; } + /// + /// Controls whether multiple choices can be selected. + /// + [JsonPropertyName("isMultiSelect")] + public bool? IsMultiSelect { get; set; } = false; /// - /// The height of the image. + /// The text to display as a placeholder when the user has not entered any value. /// - [JsonPropertyName("height")] - public string? Height { get; set; } + [JsonPropertyName("placeholder")] + public string? Placeholder { get; set; } + + /// + /// Controls if choice titles should wrap. + /// + [JsonPropertyName("wrap")] + public bool? Wrap { get; set; } = true; + + /// + /// Controls whether choice items are arranged in multiple columns in expanded mode, or in a single column. Default is false. + /// + [JsonPropertyName("useMultipleColumns")] + public bool? UseMultipleColumns { get; set; } = false; + + /// + /// The minimum width, in pixels, for each column when using a multi-column layout. This ensures that choice items remain readable even when horizontal space is limited. Default is 100 pixels. + /// + [JsonPropertyName("minColumnWidth")] + public string? MinColumnWidth { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -6467,13 +9015,20 @@ public class Image : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - public Image(string url) + public ChoiceSetInput() { } + + public ChoiceSetInput(params Choice[] choices) { - this.Url = url; + this.Choices = new List(choices); + } + + public ChoiceSetInput(IList choices) + { + this.Choices = choices; } /// - /// Serializes this Image into a JSON string. + /// Serializes this ChoiceSetInput into a JSON string. /// public string Serialize() { @@ -6487,154 +9042,279 @@ public string Serialize() ); } - public Image WithId(string value) + public ChoiceSetInput WithKey(string value) + { + this.Key = value; + return this; + } + + public ChoiceSetInput WithId(string value) { this.Id = value; return this; } - public Image WithRequires(HostCapabilities value) + public ChoiceSetInput WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public Image WithLang(string value) + public ChoiceSetInput WithLang(string value) { this.Lang = value; return this; } - public Image WithIsVisible(bool value) + public ChoiceSetInput WithIsVisible(bool value) { this.IsVisible = value; return this; } - public Image WithSeparator(bool value) + public ChoiceSetInput WithSeparator(bool value) { this.Separator = value; return this; } - public Image WithHorizontalAlignment(HorizontalAlignment value) + public ChoiceSetInput WithHeight(ElementHeight value) { - this.HorizontalAlignment = value; + this.Height = value; return this; } - public Image WithSpacing(Spacing value) + public ChoiceSetInput WithSpacing(Spacing value) { this.Spacing = value; return this; } - public Image WithTargetWidth(TargetWidth value) + public ChoiceSetInput WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public Image WithIsSortKey(bool value) + public ChoiceSetInput WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public Image WithUrl(string value) + public ChoiceSetInput WithLabel(string value) { - this.Url = value; + this.Label = value; return this; } - public Image WithAltText(string value) + public ChoiceSetInput WithIsRequired(bool value) { - this.AltText = value; + this.IsRequired = value; return this; } - public Image WithBackgroundColor(string value) + public ChoiceSetInput WithErrorMessage(string value) { - this.BackgroundColor = value; + this.ErrorMessage = value; return this; } - public Image WithStyle(ImageStyle value) + public ChoiceSetInput WithValueChangedAction(Action value) + { + this.ValueChangedAction = value; + return this; + } + + public ChoiceSetInput WithValue(string value) + { + this.Value = value; + return this; + } + + public ChoiceSetInput WithChoices(params Choice[] value) + { + this.Choices = new List(value); + return this; + } + + public ChoiceSetInput WithChoices(IList value) + { + this.Choices = value; + return this; + } + + public ChoiceSetInput WithChoicesData(QueryData value) + { + this.ChoicesData = value; + return this; + } + + public ChoiceSetInput WithStyle(ChoiceSetInputStyle value) { this.Style = value; return this; } - public Image WithSize(Size value) + public ChoiceSetInput WithIsMultiSelect(bool value) { - this.Size = value; + this.IsMultiSelect = value; return this; } - public Image WithWidth(string value) + public ChoiceSetInput WithPlaceholder(string value) { - this.Width = value; + this.Placeholder = value; return this; } - public Image WithSelectAction(Action value) + public ChoiceSetInput WithWrap(bool value) { - this.SelectAction = value; + this.Wrap = value; return this; } - public Image WithAllowExpand(bool value) + public ChoiceSetInput WithUseMultipleColumns(bool value) { - this.AllowExpand = value; + this.UseMultipleColumns = value; + return this; + } + + public ChoiceSetInput WithMinColumnWidth(string value) + { + this.MinColumnWidth = value; + return this; + } + + public ChoiceSetInput WithGridArea(string value) + { + this.GridArea = value; + return this; + } + + public ChoiceSetInput WithFallback(IUnion value) + { + this.Fallback = value; return this; } +} + +/// +/// A choice as used by the Input.ChoiceSet input. +/// +public class Choice : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type Choice. + /// + public static Choice? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The text to display for the choice. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The value associated with the choice, as sent to the Bot when an Action.Submit or Action.Execute is invoked + /// + [JsonPropertyName("value")] + public string? Value { get; set; } - public Image WithMsteams(TeamsImageProperties value) + /// + /// Serializes this Choice into a JSON string. + /// + public string Serialize() { - this.Msteams = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public Image WithHeight(string value) + public Choice WithKey(string value) { - this.Height = value; + this.Key = value; return this; } - public Image WithGridArea(string value) + public Choice WithTitle(string value) { - this.GridArea = value; + this.Title = value; return this; } - public Image WithFallback(IUnion value) + public Choice WithValue(string value) { - this.Fallback = value; + this.Value = value; return this; } } /// -/// Represents a set of Teams-specific properties on an image. +/// Defines a query to dynamically fetch data from a Bot. /// -public class TeamsImageProperties : SerializableObject +public class QueryData : SerializableObject { /// - /// Deserializes a JSON string into an object of type TeamsImageProperties. + /// Deserializes a JSON string into an object of type QueryData. /// - public static TeamsImageProperties? Deserialize(string json) + public static QueryData? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Controls if the image is expandable in Teams. This property is equivalent to the Image.allowExpand property. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("allowExpand")] - public bool? AllowExpand { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Serializes this TeamsImageProperties into a JSON string. + /// Must be **Data.Query**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "Data.Query"; + + /// + /// The dataset from which to fetch the data. + /// + [JsonPropertyName("dataset")] + public string? Dataset { get; set; } + + /// + /// Controls which inputs are associated with the Data.Query. When a Data.Query is executed, the values of the associated inputs are sent to the Bot, allowing it to perform filtering operations based on the user's input. + /// + [JsonPropertyName("associatedInputs")] + public AssociatedInputs? AssociatedInputs { get; set; } + + /// + /// The maximum number of data items that should be returned by the query. Card authors should not specify this property in their card payload. It is determined by the client and sent to the Bot to enable pagination. + /// + [JsonPropertyName("count")] + public float? Count { get; set; } + + /// + /// The number of data items to be skipped by the query. Card authors should not specify this property in their card payload. It is determined by the client and sent to the Bot to enable pagination. + /// + [JsonPropertyName("skip")] + public float? Skip { get; set; } + + /// + /// Serializes this QueryData into a JSON string. /// public string Serialize() { @@ -6648,31 +9328,61 @@ public string Serialize() ); } - public TeamsImageProperties WithAllowExpand(bool value) + public QueryData WithKey(string value) { - this.AllowExpand = value; + this.Key = value; + return this; + } + + public QueryData WithDataset(string value) + { + this.Dataset = value; + return this; + } + + public QueryData WithAssociatedInputs(AssociatedInputs value) + { + this.AssociatedInputs = value; + return this; + } + + public QueryData WithCount(float value) + { + this.Count = value; + return this; + } + + public QueryData WithSkip(float value) + { + this.Skip = value; return this; } } /// -/// An input to allow the user to enter text. +/// An input to allow the user to rate something using stars. /// -public class TextInput : CardElement +public class RatingInput : CardElement { /// - /// Deserializes a JSON string into an object of type TextInput. + /// Deserializes a JSON string into an object of type RatingInput. /// - public static TextInput? Deserialize(string json) + public static RatingInput? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Input.Text**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Input.Rating**. /// [JsonPropertyName("type")] - public string Type { get; } = "Input.Text"; + public string Type { get; } = "Input.Rating"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -6684,7 +9394,7 @@ public class TextInput : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -6696,25 +9406,25 @@ public class TextInput : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -6726,7 +9436,7 @@ public class TextInput : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// /// The label of the input. @@ -6740,7 +9450,7 @@ public class TextInput : CardElement /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. /// [JsonPropertyName("isRequired")] - public bool? IsRequired { get; set; } + public bool? IsRequired { get; set; } = false; /// /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. @@ -6752,49 +9462,37 @@ public class TextInput : CardElement /// An Action.ResetInputs action that will be executed when the value of the input changes. /// [JsonPropertyName("valueChangedAction")] - public ResetInputsAction? ValueChangedAction { get; set; } + public Action? ValueChangedAction { get; set; } /// /// The default value of the input. /// [JsonPropertyName("value")] - public string? Value { get; set; } - - /// - /// The maximum length of the text in the input. - /// - [JsonPropertyName("maxLength")] - public float? MaxLength { get; set; } - - /// - /// Controls if the input should allow multiple lines of text. - /// - [JsonPropertyName("isMultiline")] - public bool? IsMultiline { get; set; } + public float? Value { get; set; } /// - /// The text to display as a placeholder when the user hasn't entered a value. + /// The number of stars to display. /// - [JsonPropertyName("placeholder")] - public string? Placeholder { get; set; } + [JsonPropertyName("max")] + public float? Max { get; set; } = 5; /// - /// The style of the input. + /// Controls if the user can select half stars. /// - [JsonPropertyName("style")] - public InputTextStyle? Style { get; set; } + [JsonPropertyName("allowHalfSteps")] + public bool? AllowHalfSteps { get; set; } = false; /// - /// The action that should be displayed as a button alongside the input. Action.ShowCard is not supported. + /// The size of the stars. /// - [JsonPropertyName("inlineAction")] - public Action? InlineAction { get; set; } + [JsonPropertyName("size")] + public RatingSize? Size { get; set; } = RatingSize.Large; /// - /// The regular expression to validate the input. + /// The color of the stars. /// - [JsonPropertyName("regex")] - public string? Regex { get; set; } + [JsonPropertyName("color")] + public RatingColor? Color { get; set; } = RatingColor.Neutral; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -6809,7 +9507,7 @@ public class TextInput : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this TextInput into a JSON string. + /// Serializes this RatingInput into a JSON string. /// public string Serialize() { @@ -6823,133 +9521,127 @@ public string Serialize() ); } - public TextInput WithId(string value) + public RatingInput WithKey(string value) + { + this.Key = value; + return this; + } + + public RatingInput WithId(string value) { this.Id = value; return this; } - public TextInput WithRequires(HostCapabilities value) + public RatingInput WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public TextInput WithLang(string value) + public RatingInput WithLang(string value) { this.Lang = value; return this; } - public TextInput WithIsVisible(bool value) + public RatingInput WithIsVisible(bool value) { this.IsVisible = value; return this; } - public TextInput WithSeparator(bool value) + public RatingInput WithSeparator(bool value) { this.Separator = value; return this; } - public TextInput WithHeight(ElementHeight value) + public RatingInput WithHeight(ElementHeight value) { this.Height = value; return this; } - public TextInput WithSpacing(Spacing value) + public RatingInput WithSpacing(Spacing value) { this.Spacing = value; return this; } - public TextInput WithTargetWidth(TargetWidth value) + public RatingInput WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public TextInput WithIsSortKey(bool value) + public RatingInput WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public TextInput WithLabel(string value) + public RatingInput WithLabel(string value) { this.Label = value; return this; } - public TextInput WithIsRequired(bool value) + public RatingInput WithIsRequired(bool value) { this.IsRequired = value; return this; } - public TextInput WithErrorMessage(string value) + public RatingInput WithErrorMessage(string value) { this.ErrorMessage = value; return this; } - public TextInput WithValueChangedAction(ResetInputsAction value) + public RatingInput WithValueChangedAction(Action value) { this.ValueChangedAction = value; return this; } - public TextInput WithValue(string value) + public RatingInput WithValue(float value) { this.Value = value; return this; } - public TextInput WithMaxLength(float value) - { - this.MaxLength = value; - return this; - } - - public TextInput WithIsMultiline(bool value) - { - this.IsMultiline = value; - return this; - } - - public TextInput WithPlaceholder(string value) + public RatingInput WithMax(float value) { - this.Placeholder = value; + this.Max = value; return this; } - public TextInput WithStyle(InputTextStyle value) + public RatingInput WithAllowHalfSteps(bool value) { - this.Style = value; + this.AllowHalfSteps = value; return this; } - public TextInput WithInlineAction(Action value) + public RatingInput WithSize(RatingSize value) { - this.InlineAction = value; + this.Size = value; return this; } - public TextInput WithRegex(string value) + public RatingInput WithColor(RatingColor value) { - this.Regex = value; + this.Color = value; return this; } - public TextInput WithGridArea(string value) + public RatingInput WithGridArea(string value) { this.GridArea = value; return this; } - public TextInput WithFallback(IUnion value) + public RatingInput WithFallback(IUnion value) { this.Fallback = value; return this; @@ -6957,23 +9649,29 @@ public TextInput WithFallback(IUnion value) } /// -/// An input to allow the user to select a date. +/// A read-only star rating element, to display the rating of something. /// -public class DateInput : CardElement +public class Rating : CardElement { /// - /// Deserializes a JSON string into an object of type DateInput. + /// Deserializes a JSON string into an object of type Rating. /// - public static DateInput? Deserialize(string json) + public static Rating? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Input.Date**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Rating**. /// [JsonPropertyName("type")] - public string Type { get; } = "Input.Date"; + public string Type { get; } = "Rating"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -6985,7 +9683,7 @@ public class DateInput : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -6997,25 +9695,31 @@ public class DateInput : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; + + /// + /// Controls how the element should be horizontally aligned. + /// + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -7027,57 +9731,43 @@ public class DateInput : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The label of the input. - /// - /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. - /// - [JsonPropertyName("label")] - public string? Label { get; set; } - - /// - /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("isRequired")] - public bool? IsRequired { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("errorMessage")] - public string? ErrorMessage { get; set; } + /// The value of the rating. Must be between 0 and max. + /// + [JsonPropertyName("value")] + public float? Value { get; set; } /// - /// An Action.ResetInputs action that will be executed when the value of the input changes. + /// The number of "votes" associated with the rating. /// - [JsonPropertyName("valueChangedAction")] - public ResetInputsAction? ValueChangedAction { get; set; } + [JsonPropertyName("count")] + public float? Count { get; set; } /// - /// The default value of the input, in the `YYYY-MM-DD` format. + /// The number of stars to display. /// - [JsonPropertyName("value")] - public string? Value { get; set; } + [JsonPropertyName("max")] + public float? Max { get; set; } = 5; /// - /// The text to display as a placeholder when the user has not selected a date. + /// The size of the stars. /// - [JsonPropertyName("placeholder")] - public string? Placeholder { get; set; } + [JsonPropertyName("size")] + public RatingSize? Size { get; set; } = RatingSize.Large; /// - /// The minimum date that can be selected. + /// The color of the stars. /// - [JsonPropertyName("min")] - public string? Min { get; set; } + [JsonPropertyName("color")] + public RatingColor? Color { get; set; } = RatingColor.Neutral; /// - /// The maximum date that can be selected. + /// The style of the stars. /// - [JsonPropertyName("max")] - public string? Max { get; set; } + [JsonPropertyName("style")] + public RatingStyle? Style { get; set; } = RatingStyle.Default; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -7092,7 +9782,7 @@ public class DateInput : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this DateInput into a JSON string. + /// Serializes this Rating into a JSON string. /// public string Serialize() { @@ -7106,115 +9796,115 @@ public string Serialize() ); } - public DateInput WithId(string value) + public Rating WithKey(string value) + { + this.Key = value; + return this; + } + + public Rating WithId(string value) { this.Id = value; return this; } - public DateInput WithRequires(HostCapabilities value) + public Rating WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public DateInput WithLang(string value) + public Rating WithLang(string value) { this.Lang = value; return this; } - public DateInput WithIsVisible(bool value) + public Rating WithIsVisible(bool value) { this.IsVisible = value; return this; } - public DateInput WithSeparator(bool value) + public Rating WithSeparator(bool value) { this.Separator = value; return this; } - public DateInput WithHeight(ElementHeight value) + public Rating WithHeight(ElementHeight value) { this.Height = value; return this; } - public DateInput WithSpacing(Spacing value) - { - this.Spacing = value; - return this; - } - - public DateInput WithTargetWidth(TargetWidth value) + public Rating WithHorizontalAlignment(HorizontalAlignment value) { - this.TargetWidth = value; + this.HorizontalAlignment = value; return this; } - public DateInput WithIsSortKey(bool value) + public Rating WithSpacing(Spacing value) { - this.IsSortKey = value; + this.Spacing = value; return this; } - public DateInput WithLabel(string value) + public Rating WithTargetWidth(TargetWidth value) { - this.Label = value; + this.TargetWidth = value; return this; } - public DateInput WithIsRequired(bool value) + public Rating WithIsSortKey(bool value) { - this.IsRequired = value; + this.IsSortKey = value; return this; } - public DateInput WithErrorMessage(string value) + public Rating WithValue(float value) { - this.ErrorMessage = value; + this.Value = value; return this; } - public DateInput WithValueChangedAction(ResetInputsAction value) + public Rating WithCount(float value) { - this.ValueChangedAction = value; + this.Count = value; return this; } - public DateInput WithValue(string value) + public Rating WithMax(float value) { - this.Value = value; + this.Max = value; return this; } - public DateInput WithPlaceholder(string value) + public Rating WithSize(RatingSize value) { - this.Placeholder = value; + this.Size = value; return this; } - public DateInput WithMin(string value) + public Rating WithColor(RatingColor value) { - this.Min = value; + this.Color = value; return this; } - public DateInput WithMax(string value) + public Rating WithStyle(RatingStyle value) { - this.Max = value; + this.Style = value; return this; } - public DateInput WithGridArea(string value) + public Rating WithGridArea(string value) { this.GridArea = value; return this; } - public DateInput WithFallback(IUnion value) + public Rating WithFallback(IUnion value) { this.Fallback = value; return this; @@ -7222,23 +9912,29 @@ public DateInput WithFallback(IUnion value) } /// -/// An input to allow the user to select a time. +/// A special type of button with an icon, title and description. /// -public class TimeInput : CardElement +public class CompoundButton : CardElement { /// - /// Deserializes a JSON string into an object of type TimeInput. + /// Deserializes a JSON string into an object of type CompoundButton. /// - public static TimeInput? Deserialize(string json) + public static CompoundButton? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Input.Time**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **CompoundButton**. /// [JsonPropertyName("type")] - public string Type { get; } = "Input.Time"; + public string Type { get; } = "CompoundButton"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -7250,7 +9946,7 @@ public class TimeInput : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -7262,25 +9958,31 @@ public class TimeInput : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; + + /// + /// Controls how the element should be horizontally aligned. + /// + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -7292,57 +9994,37 @@ public class TimeInput : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The label of the input. - /// - /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. - /// - [JsonPropertyName("label")] - public string? Label { get; set; } - - /// - /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("isRequired")] - public bool? IsRequired { get; set; } - - /// - /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("errorMessage")] - public string? ErrorMessage { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// An Action.ResetInputs action that will be executed when the value of the input changes. + /// The icon to show on the button. /// - [JsonPropertyName("valueChangedAction")] - public ResetInputsAction? ValueChangedAction { get; set; } + [JsonPropertyName("icon")] + public IconInfo? Icon { get; set; } /// - /// The default value of the input, in the `HH:MM` format. + /// The badge to show on the button. /// - [JsonPropertyName("value")] - public string? Value { get; set; } + [JsonPropertyName("badge")] + public string? Badge { get; set; } /// - /// The text to display as a placeholder when the user hasn't entered a value. + /// The title of the button. /// - [JsonPropertyName("placeholder")] - public string? Placeholder { get; set; } + [JsonPropertyName("title")] + public string? Title { get; set; } /// - /// The minimum time that can be selected, in the `HH:MM` format. + /// The description text of the button. /// - [JsonPropertyName("min")] - public string? Min { get; set; } + [JsonPropertyName("description")] + public string? Description { get; set; } /// - /// The maximum time that can be selected, in the `HH:MM` format. + /// An Action that will be invoked when the button is tapped or clicked. Action.ShowCard is not supported. /// - [JsonPropertyName("max")] - public string? Max { get; set; } + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -7357,7 +10039,7 @@ public class TimeInput : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this TimeInput into a JSON string. + /// Serializes this CompoundButton into a JSON string. /// public string Serialize() { @@ -7371,139 +10053,228 @@ public string Serialize() ); } - public TimeInput WithId(string value) + public CompoundButton WithKey(string value) + { + this.Key = value; + return this; + } + + public CompoundButton WithId(string value) { this.Id = value; return this; } - public TimeInput WithRequires(HostCapabilities value) + public CompoundButton WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public TimeInput WithLang(string value) + public CompoundButton WithLang(string value) { this.Lang = value; return this; } - public TimeInput WithIsVisible(bool value) + public CompoundButton WithIsVisible(bool value) { this.IsVisible = value; return this; } - public TimeInput WithSeparator(bool value) + public CompoundButton WithSeparator(bool value) { this.Separator = value; return this; } - public TimeInput WithHeight(ElementHeight value) + public CompoundButton WithHeight(ElementHeight value) { this.Height = value; return this; } - public TimeInput WithSpacing(Spacing value) + public CompoundButton WithHorizontalAlignment(HorizontalAlignment value) + { + this.HorizontalAlignment = value; + return this; + } + + public CompoundButton WithSpacing(Spacing value) { this.Spacing = value; return this; } - public TimeInput WithTargetWidth(TargetWidth value) + public CompoundButton WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public TimeInput WithIsSortKey(bool value) + public CompoundButton WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public TimeInput WithLabel(string value) + public CompoundButton WithIcon(IconInfo value) { - this.Label = value; + this.Icon = value; return this; } - public TimeInput WithIsRequired(bool value) + public CompoundButton WithBadge(string value) { - this.IsRequired = value; + this.Badge = value; + return this; + } + + public CompoundButton WithTitle(string value) + { + this.Title = value; + return this; + } + + public CompoundButton WithDescription(string value) + { + this.Description = value; + return this; + } + + public CompoundButton WithSelectAction(Action value) + { + this.SelectAction = value; + return this; + } + + public CompoundButton WithGridArea(string value) + { + this.GridArea = value; + return this; + } + + public CompoundButton WithFallback(IUnion value) + { + this.Fallback = value; return this; } +} + +/// +/// Defines information about a Fluent icon and how it should be rendered. +/// +public class IconInfo : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type IconInfo. + /// + public static IconInfo? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The name of the icon to display. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } - public TimeInput WithErrorMessage(string value) - { - this.ErrorMessage = value; - return this; - } + /// + /// The size of the icon. + /// + [JsonPropertyName("size")] + public IconSize? Size { get; set; } = IconSize.XSmall; - public TimeInput WithValueChangedAction(ResetInputsAction value) - { - this.ValueChangedAction = value; - return this; - } + /// + /// The style of the icon. + /// + [JsonPropertyName("style")] + public IconStyle? Style { get; set; } = IconStyle.Regular; - public TimeInput WithValue(string value) + /// + /// The color of the icon. + /// + [JsonPropertyName("color")] + public TextColor? Color { get; set; } = TextColor.Default; + + /// + /// Serializes this IconInfo into a JSON string. + /// + public string Serialize() { - this.Value = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public TimeInput WithPlaceholder(string value) + public IconInfo WithKey(string value) { - this.Placeholder = value; + this.Key = value; return this; } - public TimeInput WithMin(string value) + public IconInfo WithName(string value) { - this.Min = value; + this.Name = value; return this; } - public TimeInput WithMax(string value) + public IconInfo WithSize(IconSize value) { - this.Max = value; + this.Size = value; return this; } - public TimeInput WithGridArea(string value) + public IconInfo WithStyle(IconStyle value) { - this.GridArea = value; + this.Style = value; return this; } - public TimeInput WithFallback(IUnion value) + public IconInfo WithColor(TextColor value) { - this.Fallback = value; + this.Color = value; return this; } } /// -/// An input to allow the user to enter a number. +/// A standalone icon element. Icons can be picked from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog). /// -public class NumberInput : CardElement +public class Icon : CardElement { /// - /// Deserializes a JSON string into an object of type NumberInput. + /// Deserializes a JSON string into an object of type Icon. /// - public static NumberInput? Deserialize(string json) + public static Icon? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Input.Number**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Icon**. /// [JsonPropertyName("type")] - public string Type { get; } = "Input.Number"; + public string Type { get; } = "Icon"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -7515,7 +10286,7 @@ public class NumberInput : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -7527,25 +10298,25 @@ public class NumberInput : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// Controls how the element should be horizontally aligned. /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -7557,57 +10328,37 @@ public class NumberInput : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The label of the input. - /// - /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. - /// - [JsonPropertyName("label")] - public string? Label { get; set; } - - /// - /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("isRequired")] - public bool? IsRequired { get; set; } - - /// - /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("errorMessage")] - public string? ErrorMessage { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// An Action.ResetInputs action that will be executed when the value of the input changes. + /// The name of the icon to display. /// - [JsonPropertyName("valueChangedAction")] - public ResetInputsAction? ValueChangedAction { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// - /// The default value of the input. + /// The size of the icon. /// - [JsonPropertyName("value")] - public float? Value { get; set; } + [JsonPropertyName("size")] + public IconSize? Size { get; set; } = IconSize.Standard; /// - /// The text to display as a placeholder when the user hasn't entered a value. + /// The style of the icon. /// - [JsonPropertyName("placeholder")] - public string? Placeholder { get; set; } + [JsonPropertyName("style")] + public IconStyle? Style { get; set; } = IconStyle.Regular; /// - /// The minimum value that can be entered. + /// The color of the icon. /// - [JsonPropertyName("min")] - public float? Min { get; set; } + [JsonPropertyName("color")] + public TextColor? Color { get; set; } = TextColor.Default; /// - /// The maximum value that can be entered. + /// An Action that will be invoked when the icon is tapped or clicked. Action.ShowCard is not supported. /// - [JsonPropertyName("max")] - public float? Max { get; set; } + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -7621,8 +10372,15 @@ public class NumberInput : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } + public Icon() { } + + public Icon(string name) + { + this.Name = name; + } + /// - /// Serializes this NumberInput into a JSON string. + /// Serializes this Icon into a JSON string. /// public string Serialize() { @@ -7636,115 +10394,103 @@ public string Serialize() ); } - public NumberInput WithId(string value) + public Icon WithKey(string value) + { + this.Key = value; + return this; + } + + public Icon WithId(string value) { this.Id = value; return this; } - public NumberInput WithRequires(HostCapabilities value) + public Icon WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public NumberInput WithLang(string value) + public Icon WithLang(string value) { this.Lang = value; return this; } - public NumberInput WithIsVisible(bool value) + public Icon WithIsVisible(bool value) { this.IsVisible = value; return this; } - public NumberInput WithSeparator(bool value) + public Icon WithSeparator(bool value) { this.Separator = value; return this; } - public NumberInput WithHeight(ElementHeight value) + public Icon WithHorizontalAlignment(HorizontalAlignment value) { - this.Height = value; + this.HorizontalAlignment = value; return this; } - public NumberInput WithSpacing(Spacing value) + public Icon WithSpacing(Spacing value) { this.Spacing = value; return this; } - public NumberInput WithTargetWidth(TargetWidth value) + public Icon WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public NumberInput WithIsSortKey(bool value) + public Icon WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public NumberInput WithLabel(string value) - { - this.Label = value; - return this; - } - - public NumberInput WithIsRequired(bool value) - { - this.IsRequired = value; - return this; - } - - public NumberInput WithErrorMessage(string value) - { - this.ErrorMessage = value; - return this; - } - - public NumberInput WithValueChangedAction(ResetInputsAction value) + public Icon WithName(string value) { - this.ValueChangedAction = value; + this.Name = value; return this; } - public NumberInput WithValue(float value) + public Icon WithSize(IconSize value) { - this.Value = value; + this.Size = value; return this; } - public NumberInput WithPlaceholder(string value) + public Icon WithStyle(IconStyle value) { - this.Placeholder = value; + this.Style = value; return this; } - public NumberInput WithMin(float value) + public Icon WithColor(TextColor value) { - this.Min = value; + this.Color = value; return this; } - public NumberInput WithMax(float value) + public Icon WithSelectAction(Action value) { - this.Max = value; + this.SelectAction = value; return this; } - public NumberInput WithGridArea(string value) + public Icon WithGridArea(string value) { this.GridArea = value; return this; } - public NumberInput WithFallback(IUnion value) + public Icon WithFallback(IUnion value) { this.Fallback = value; return this; @@ -7752,23 +10498,29 @@ public NumberInput WithFallback(IUnion value) } /// -/// An input to allow the user to select between on/off states. +/// A carousel with sliding pages. /// -public class ToggleInput : CardElement +public class Carousel : CardElement { /// - /// Deserializes a JSON string into an object of type ToggleInput. + /// Deserializes a JSON string into an object of type Carousel. /// - public static ToggleInput? Deserialize(string json) + public static Carousel? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Input.Toggle**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Carousel**. /// [JsonPropertyName("type")] - public string Type { get; } = "Input.Toggle"; + public string Type { get; } = "Carousel"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -7780,7 +10532,7 @@ public class ToggleInput : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -7792,25 +10544,25 @@ public class ToggleInput : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -7822,63 +10574,25 @@ public class ToggleInput : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The label of the input. - /// - /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. - /// - [JsonPropertyName("label")] - public string? Label { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("isRequired")] - public bool? IsRequired { get; set; } - - /// - /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("errorMessage")] - public string? ErrorMessage { get; set; } - - /// - /// An Action.ResetInputs action that will be executed when the value of the input changes. - /// - [JsonPropertyName("valueChangedAction")] - public ResetInputsAction? ValueChangedAction { get; set; } - - /// - /// The default value of the input. - /// - [JsonPropertyName("value")] - public string? Value { get; set; } - - /// - /// The title (caption) to display next to the toggle. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// The value to send to the Bot when the toggle is on. + /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. /// - [JsonPropertyName("valueOn")] - public string? ValueOn { get; set; } + [JsonPropertyName("bleed")] + public bool? Bleed { get; set; } = false; /// - /// The value to send to the Bot when the toggle is off. + /// The minimum height, in pixels, of the container, in the `px` format. /// - [JsonPropertyName("valueOff")] - public string? ValueOff { get; set; } + [JsonPropertyName("minHeight")] + public string? MinHeight { get; set; } /// - /// Controls if the title should wrap. + /// Controls the type of animation to use to navigate between pages. /// - [JsonPropertyName("wrap")] - public bool? Wrap { get; set; } + [JsonPropertyName("pageAnimation")] + public CarouselPageAnimation? PageAnimation { get; set; } = CarouselPageAnimation.Slide; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -7892,13 +10606,14 @@ public class ToggleInput : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - public ToggleInput(string title) - { - this.Title = title; - } + /// + /// The pages in the carousel. + /// + [JsonPropertyName("pages")] + public IList? Pages { get; set; } /// - /// Serializes this ToggleInput into a JSON string. + /// Serializes this Carousel into a JSON string. /// public string Serialize() { @@ -7912,145 +10627,133 @@ public string Serialize() ); } - public ToggleInput WithId(string value) + public Carousel WithKey(string value) + { + this.Key = value; + return this; + } + + public Carousel WithId(string value) { this.Id = value; return this; } - public ToggleInput WithRequires(HostCapabilities value) + public Carousel WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ToggleInput WithLang(string value) + public Carousel WithLang(string value) { this.Lang = value; return this; } - public ToggleInput WithIsVisible(bool value) + public Carousel WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ToggleInput WithSeparator(bool value) + public Carousel WithSeparator(bool value) { this.Separator = value; return this; } - public ToggleInput WithHeight(ElementHeight value) + public Carousel WithHeight(ElementHeight value) { this.Height = value; return this; } - public ToggleInput WithSpacing(Spacing value) + public Carousel WithSpacing(Spacing value) { this.Spacing = value; return this; } - public ToggleInput WithTargetWidth(TargetWidth value) + public Carousel WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public ToggleInput WithIsSortKey(bool value) + public Carousel WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public ToggleInput WithLabel(string value) - { - this.Label = value; - return this; - } - - public ToggleInput WithIsRequired(bool value) - { - this.IsRequired = value; - return this; - } - - public ToggleInput WithErrorMessage(string value) - { - this.ErrorMessage = value; - return this; - } - - public ToggleInput WithValueChangedAction(ResetInputsAction value) - { - this.ValueChangedAction = value; - return this; - } - - public ToggleInput WithValue(string value) + public Carousel WithBleed(bool value) { - this.Value = value; + this.Bleed = value; return this; } - public ToggleInput WithTitle(string value) + public Carousel WithMinHeight(string value) { - this.Title = value; + this.MinHeight = value; return this; } - public ToggleInput WithValueOn(string value) + public Carousel WithPageAnimation(CarouselPageAnimation value) { - this.ValueOn = value; + this.PageAnimation = value; return this; } - public ToggleInput WithValueOff(string value) + public Carousel WithGridArea(string value) { - this.ValueOff = value; + this.GridArea = value; return this; } - public ToggleInput WithWrap(bool value) + public Carousel WithFallback(IUnion value) { - this.Wrap = value; + this.Fallback = value; return this; } - public ToggleInput WithGridArea(string value) + public Carousel WithPages(params CarouselPage[] value) { - this.GridArea = value; + this.Pages = new List(value); return this; } - public ToggleInput WithFallback(IUnion value) + public Carousel WithPages(IList value) { - this.Fallback = value; + this.Pages = value; return this; } } /// -/// An input to allow the user to select one or more values. +/// A badge element to show an icon and/or text in a compact form over a colored background. /// -public class ChoiceSetInput : CardElement +public class Badge : CardElement { /// - /// Deserializes a JSON string into an object of type ChoiceSetInput. + /// Deserializes a JSON string into an object of type Badge. /// - public static ChoiceSetInput? Deserialize(string json) + public static Badge? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Input.ChoiceSet**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Badge**. /// [JsonPropertyName("type")] - public string Type { get; } = "Input.ChoiceSet"; + public string Type { get; } = "Badge"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -8062,7 +10765,7 @@ public class ChoiceSetInput : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -8074,25 +10777,31 @@ public class ChoiceSetInput : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; + + /// + /// Controls how the element should be horizontally aligned. + /// + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -8104,87 +10813,55 @@ public class ChoiceSetInput : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The label of the input. - /// - /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. - /// - [JsonPropertyName("label")] - public string? Label { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// The text to display. /// - [JsonPropertyName("isRequired")] - public bool? IsRequired { get; set; } + [JsonPropertyName("text")] + public string? Text { get; set; } /// - /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// The name of an icon from the [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) to display, in the `[,regular|filled]` format. If the style is not specified, the regular style is used. /// - [JsonPropertyName("errorMessage")] - public string? ErrorMessage { get; set; } + [JsonPropertyName("icon")] + public string? Icon { get; set; } /// - /// An Action.ResetInputs action that will be executed when the value of the input changes. + /// Controls the position of the icon. /// - [JsonPropertyName("valueChangedAction")] - public ResetInputsAction? ValueChangedAction { get; set; } + [JsonPropertyName("iconPosition")] + public BadgeIconPosition? IconPosition { get; set; } = BadgeIconPosition.Before; /// - /// The default value of the input. + /// Controls the strength of the background color. /// - [JsonPropertyName("value")] - public string? Value { get; set; } + [JsonPropertyName("appearance")] + public BadgeAppearance? Appearance { get; set; } = BadgeAppearance.Filled; /// - /// The choices associated with the input. + /// The size of the badge. /// - [JsonPropertyName("choices")] - public IList? Choices { get; set; } + [JsonPropertyName("size")] + public BadgeSize? Size { get; set; } = BadgeSize.Medium; /// - /// A Data.Query object that defines the dataset from which to dynamically fetch the choices for the input. + /// Controls the shape of the badge. /// - [JsonPropertyName("choices.data")] - public QueryData? ChoicesData { get; set; } + [JsonPropertyName("shape")] + public BadgeShape? Shape { get; set; } = BadgeShape.Circular; /// - /// Controls whether the input should be displayed as a dropdown (compact) or a list of radio buttons or checkboxes (expanded). + /// The style of the badge. /// [JsonPropertyName("style")] - public StyleEnum? Style { get; set; } - - /// - /// Controls whether multiple choices can be selected. - /// - [JsonPropertyName("isMultiSelect")] - public bool? IsMultiSelect { get; set; } - - /// - /// The text to display as a placeholder when the user has not entered any value. - /// - [JsonPropertyName("placeholder")] - public string? Placeholder { get; set; } - - /// - /// Controls if choice titles should wrap. - /// - [JsonPropertyName("wrap")] - public bool? Wrap { get; set; } - - /// - /// Controls whether choice items are arranged in multiple columns in expanded mode, or in a single column. Default is false. - /// - [JsonPropertyName("useMultipleColumns")] - public bool? UseMultipleColumns { get; set; } + public BadgeStyle? Style { get; set; } = BadgeStyle.Default; /// - /// The minimum width, in pixels, for each column when using a multi-column layout. This ensures that choice items remain readable even when horizontal space is limited. Default is 100 pixels. + /// Controls the tooltip text to display when the badge is hovered over. /// - [JsonPropertyName("minColumnWidth")] - public string? MinColumnWidth { get; set; } + [JsonPropertyName("tooltip")] + public string? Tooltip { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -8198,13 +10875,8 @@ public class ChoiceSetInput : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - public ChoiceSetInput(params IList choices) - { - this.Choices = choices; - } - /// - /// Serializes this ChoiceSetInput into a JSON string. + /// Serializes this Badge into a JSON string. /// public string Serialize() { @@ -8218,145 +10890,127 @@ public string Serialize() ); } - public ChoiceSetInput WithId(string value) + public Badge WithKey(string value) + { + this.Key = value; + return this; + } + + public Badge WithId(string value) { this.Id = value; return this; } - public ChoiceSetInput WithRequires(HostCapabilities value) + public Badge WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ChoiceSetInput WithLang(string value) + public Badge WithLang(string value) { this.Lang = value; return this; } - public ChoiceSetInput WithIsVisible(bool value) + public Badge WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ChoiceSetInput WithSeparator(bool value) + public Badge WithSeparator(bool value) { this.Separator = value; return this; } - public ChoiceSetInput WithHeight(ElementHeight value) + public Badge WithHeight(ElementHeight value) { this.Height = value; return this; } - public ChoiceSetInput WithSpacing(Spacing value) - { - this.Spacing = value; - return this; - } - - public ChoiceSetInput WithTargetWidth(TargetWidth value) - { - this.TargetWidth = value; - return this; - } - - public ChoiceSetInput WithIsSortKey(bool value) - { - this.IsSortKey = value; - return this; - } - - public ChoiceSetInput WithLabel(string value) - { - this.Label = value; - return this; - } - - public ChoiceSetInput WithIsRequired(bool value) + public Badge WithHorizontalAlignment(HorizontalAlignment value) { - this.IsRequired = value; + this.HorizontalAlignment = value; return this; } - public ChoiceSetInput WithErrorMessage(string value) + public Badge WithSpacing(Spacing value) { - this.ErrorMessage = value; + this.Spacing = value; return this; } - public ChoiceSetInput WithValueChangedAction(ResetInputsAction value) + public Badge WithTargetWidth(TargetWidth value) { - this.ValueChangedAction = value; + this.TargetWidth = value; return this; } - public ChoiceSetInput WithValue(string value) + public Badge WithIsSortKey(bool value) { - this.Value = value; + this.IsSortKey = value; return this; } - public ChoiceSetInput WithChoices(params IList value) + public Badge WithText(string value) { - this.Choices = value; + this.Text = value; return this; } - public ChoiceSetInput WithChoicesData(QueryData value) + public Badge WithIcon(string value) { - this.ChoicesData = value; + this.Icon = value; return this; } - public ChoiceSetInput WithStyle(StyleEnum value) + public Badge WithIconPosition(BadgeIconPosition value) { - this.Style = value; + this.IconPosition = value; return this; } - public ChoiceSetInput WithIsMultiSelect(bool value) + public Badge WithAppearance(BadgeAppearance value) { - this.IsMultiSelect = value; + this.Appearance = value; return this; } - public ChoiceSetInput WithPlaceholder(string value) + public Badge WithSize(BadgeSize value) { - this.Placeholder = value; + this.Size = value; return this; } - public ChoiceSetInput WithWrap(bool value) + public Badge WithShape(BadgeShape value) { - this.Wrap = value; + this.Shape = value; return this; } - public ChoiceSetInput WithUseMultipleColumns(bool value) + public Badge WithStyle(BadgeStyle value) { - this.UseMultipleColumns = value; + this.Style = value; return this; } - public ChoiceSetInput WithMinColumnWidth(string value) + public Badge WithTooltip(string value) { - this.MinColumnWidth = value; + this.Tooltip = value; return this; } - public ChoiceSetInput WithGridArea(string value) + public Badge WithGridArea(string value) { this.GridArea = value; return this; } - public ChoiceSetInput WithFallback(IUnion value) + public Badge WithFallback(IUnion value) { this.Fallback = value; return this; @@ -8364,103 +11018,122 @@ public ChoiceSetInput WithFallback(IUnion value) } /// -/// A choice as used by the Input.ChoiceSet input. +/// A spinning ring element, to indicate progress. /// -public class Choice : SerializableObject +public class ProgressRing : CardElement { /// - /// Deserializes a JSON string into an object of type Choice. + /// Deserializes a JSON string into an object of type ProgressRing. /// - public static Choice? Deserialize(string json) + public static ProgressRing? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The text to display for the choice. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("title")] - public string? Title { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The value associated with the choice, as sent to the Bot when an Action.Submit or Action.Execute is invoked + /// Must be **ProgressRing**. /// - [JsonPropertyName("value")] - public string? Value { get; set; } + [JsonPropertyName("type")] + public string Type { get; } = "ProgressRing"; /// - /// Serializes this Choice into a JSON string. + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } + [JsonPropertyName("id")] + public string? Id { get; set; } - public Choice WithTitle(string value) - { - this.Title = value; - return this; - } + /// + /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). + /// + [JsonPropertyName("requires")] + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); - public Choice WithValue(string value) - { - this.Value = value; - return this; - } -} + /// + /// The locale associated with the element. + /// + [JsonPropertyName("lang")] + public string? Lang { get; set; } -/// -/// Defines a query to dynamically fetch data from a Bot. -/// -public class QueryData : SerializableObject -{ /// - /// Deserializes a JSON string into an object of type QueryData. + /// Controls the visibility of the element. /// - public static QueryData? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } = true; /// - /// Must be **Data.Query**. + /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// - [JsonPropertyName("type")] - public string Type { get; } = "Data.Query"; + [JsonPropertyName("separator")] + public bool? Separator { get; set; } = false; /// - /// The dataset from which to fetch the data. + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// - [JsonPropertyName("dataset")] - public string? Dataset { get; set; } + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// - /// Controls which inputs are associated with the Data.Query. When a Data.Query is executed, the values of the associated inputs are sent to the Bot, allowing it to perform filtering operations based on the user's input. + /// Controls how the element should be horizontally aligned. /// - [JsonPropertyName("associatedInputs")] - public AssociatedInputs? AssociatedInputs { get; set; } + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// - /// The maximum number of data items that should be returned by the query. Card authors should not specify this property in their card payload. It is determined by the client and sent to the Bot to enable pagination. + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// - [JsonPropertyName("count")] - public float? Count { get; set; } + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; /// - /// The number of data items to be skipped by the query. Card authors should not specify this property in their card payload. It is determined by the client and sent to the Bot to enable pagination. + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). /// - [JsonPropertyName("skip")] - public float? Skip { get; set; } + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } /// - /// Serializes this QueryData into a JSON string. + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; + + /// + /// The label of the progress ring. + /// + [JsonPropertyName("label")] + public string? Label { get; set; } + + /// + /// Controls the relative position of the label to the progress ring. + /// + [JsonPropertyName("labelPosition")] + public ProgressRingLabelPosition? LabelPosition { get; set; } = ProgressRingLabelPosition.Below; + + /// + /// The size of the progress ring. + /// + [JsonPropertyName("size")] + public ProgressRingSize? Size { get; set; } = ProgressRingSize.Medium; + + /// + /// The area of a Layout.AreaGrid layout in which an element should be displayed. + /// + [JsonPropertyName("grid.area")] + public string? GridArea { get; set; } + + /// + /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + /// + /// Serializes this ProgressRing into a JSON string. /// public string Serialize() { @@ -8474,49 +11147,127 @@ public string Serialize() ); } - public QueryData WithDataset(string value) + public ProgressRing WithKey(string value) { - this.Dataset = value; + this.Key = value; return this; } - public QueryData WithAssociatedInputs(AssociatedInputs value) + public ProgressRing WithId(string value) { - this.AssociatedInputs = value; + this.Id = value; return this; } - public QueryData WithCount(float value) + public ProgressRing WithRequires(HostCapabilities value) { - this.Count = value; + this.Requires = value; return this; } - public QueryData WithSkip(float value) + public ProgressRing WithLang(string value) { - this.Skip = value; + this.Lang = value; + return this; + } + + public ProgressRing WithIsVisible(bool value) + { + this.IsVisible = value; + return this; + } + + public ProgressRing WithSeparator(bool value) + { + this.Separator = value; + return this; + } + + public ProgressRing WithHeight(ElementHeight value) + { + this.Height = value; + return this; + } + + public ProgressRing WithHorizontalAlignment(HorizontalAlignment value) + { + this.HorizontalAlignment = value; + return this; + } + + public ProgressRing WithSpacing(Spacing value) + { + this.Spacing = value; + return this; + } + + public ProgressRing WithTargetWidth(TargetWidth value) + { + this.TargetWidth = value; + return this; + } + + public ProgressRing WithIsSortKey(bool value) + { + this.IsSortKey = value; + return this; + } + + public ProgressRing WithLabel(string value) + { + this.Label = value; + return this; + } + + public ProgressRing WithLabelPosition(ProgressRingLabelPosition value) + { + this.LabelPosition = value; + return this; + } + + public ProgressRing WithSize(ProgressRingSize value) + { + this.Size = value; + return this; + } + + public ProgressRing WithGridArea(string value) + { + this.GridArea = value; + return this; + } + + public ProgressRing WithFallback(IUnion value) + { + this.Fallback = value; return this; } } /// -/// An input to allow the user to rate something using stars. +/// A progress bar element, to represent a value within a range. /// -public class RatingInput : CardElement +public class ProgressBar : CardElement { /// - /// Deserializes a JSON string into an object of type RatingInput. + /// Deserializes a JSON string into an object of type ProgressBar. /// - public static RatingInput? Deserialize(string json) + public static ProgressBar? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Input.Rating**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **ProgressBar**. /// [JsonPropertyName("type")] - public string Type { get; } = "Input.Rating"; + public string Type { get; } = "ProgressBar"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -8528,7 +11279,7 @@ public class RatingInput : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -8540,93 +11291,61 @@ public class RatingInput : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. - /// - [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } - - /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). - /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } - - /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. - /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The label of the input. - /// - /// A label should **always** be provided to ensure the best user experience especially for users of assistive technology. - /// - [JsonPropertyName("label")] - public string? Label { get; set; } - - /// - /// Controls whether the input is required. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. - /// - [JsonPropertyName("isRequired")] - public bool? IsRequired { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// - /// The error message to display when the input fails validation. See [Input validation](https://adaptivecards.microsoft.com/?topic=input-validation) for more details. + /// Controls how the element should be horizontally aligned. /// - [JsonPropertyName("errorMessage")] - public string? ErrorMessage { get; set; } + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// - /// An Action.ResetInputs action that will be executed when the value of the input changes. + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// - [JsonPropertyName("valueChangedAction")] - public ResetInputsAction? ValueChangedAction { get; set; } + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; /// - /// The default value of the input. + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). /// - [JsonPropertyName("value")] - public float? Value { get; set; } + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } /// - /// The number of stars to display. + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// - [JsonPropertyName("max")] - public float? Max { get; set; } + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; /// - /// Controls if the user can select half stars. + /// The value of the progress bar. Must be between 0 and max. /// - [JsonPropertyName("allowHalfSteps")] - public bool? AllowHalfSteps { get; set; } + [JsonPropertyName("value")] + public float? Value { get; set; } /// - /// The size of the stars. + /// The maximum value of the progress bar. /// - [JsonPropertyName("size")] - public RatingSize? Size { get; set; } + [JsonPropertyName("max")] + public float? Max { get; set; } = 100; /// - /// The color of the stars. + /// The color of the progress bar. `color` has no effect when the `ProgressBar` is in indeterminate mode, in which case the "accent" color is always used. /// [JsonPropertyName("color")] - public RatingColor? Color { get; set; } + public ProgressBarColor? Color { get; set; } = ProgressBarColor.Accent; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -8641,7 +11360,7 @@ public class RatingInput : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this RatingInput into a JSON string. + /// Serializes this ProgressBar into a JSON string. /// public string Serialize() { @@ -8655,121 +11374,97 @@ public string Serialize() ); } - public RatingInput WithId(string value) + public ProgressBar WithKey(string value) + { + this.Key = value; + return this; + } + + public ProgressBar WithId(string value) { this.Id = value; return this; } - public RatingInput WithRequires(HostCapabilities value) + public ProgressBar WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public RatingInput WithLang(string value) + public ProgressBar WithLang(string value) { this.Lang = value; return this; } - public RatingInput WithIsVisible(bool value) + public ProgressBar WithIsVisible(bool value) { this.IsVisible = value; return this; } - public RatingInput WithSeparator(bool value) + public ProgressBar WithSeparator(bool value) { this.Separator = value; return this; } - public RatingInput WithHeight(ElementHeight value) + public ProgressBar WithHeight(ElementHeight value) { this.Height = value; return this; } - public RatingInput WithSpacing(Spacing value) - { - this.Spacing = value; - return this; - } - - public RatingInput WithTargetWidth(TargetWidth value) - { - this.TargetWidth = value; - return this; - } - - public RatingInput WithIsSortKey(bool value) - { - this.IsSortKey = value; - return this; - } - - public RatingInput WithLabel(string value) + public ProgressBar WithHorizontalAlignment(HorizontalAlignment value) { - this.Label = value; + this.HorizontalAlignment = value; return this; } - public RatingInput WithIsRequired(bool value) + public ProgressBar WithSpacing(Spacing value) { - this.IsRequired = value; + this.Spacing = value; return this; } - public RatingInput WithErrorMessage(string value) + public ProgressBar WithTargetWidth(TargetWidth value) { - this.ErrorMessage = value; + this.TargetWidth = value; return this; } - public RatingInput WithValueChangedAction(ResetInputsAction value) + public ProgressBar WithIsSortKey(bool value) { - this.ValueChangedAction = value; + this.IsSortKey = value; return this; } - public RatingInput WithValue(float value) + public ProgressBar WithValue(float value) { this.Value = value; return this; } - public RatingInput WithMax(float value) + public ProgressBar WithMax(float value) { this.Max = value; return this; } - public RatingInput WithAllowHalfSteps(bool value) - { - this.AllowHalfSteps = value; - return this; - } - - public RatingInput WithSize(RatingSize value) - { - this.Size = value; - return this; - } - - public RatingInput WithColor(RatingColor value) + public ProgressBar WithColor(ProgressBarColor value) { this.Color = value; return this; } - public RatingInput WithGridArea(string value) + public ProgressBar WithGridArea(string value) { this.GridArea = value; return this; } - public RatingInput WithFallback(IUnion value) + public ProgressBar WithFallback(IUnion value) { this.Fallback = value; return this; @@ -8777,23 +11472,29 @@ public RatingInput WithFallback(IUnion value) } /// -/// A read-only star rating element, to display the rating of something. +/// A donut chart. /// -public class Rating : CardElement +public class DonutChart : CardElement { /// - /// Deserializes a JSON string into an object of type Rating. + /// Deserializes a JSON string into an object of type DonutChart. /// - public static Rating? Deserialize(string json) + public static DonutChart? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Rating**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Chart.Donut**. /// [JsonPropertyName("type")] - public string Type { get; } = "Rating"; + public string Type { get; } = "Chart.Donut"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -8805,7 +11506,7 @@ public class Rating : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -8817,19 +11518,19 @@ public class Rating : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -8841,7 +11542,7 @@ public class Rating : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -8853,43 +11554,67 @@ public class Rating : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The value of the rating. Must be between 0 and max. + /// The title of the chart. /// - [JsonPropertyName("value")] - public float? Value { get; set; } + [JsonPropertyName("title")] + public string? Title { get; set; } /// - /// The number of "votes" associated with the rating. + /// Controls whether the chart's title should be displayed. Defaults to `false`. /// - [JsonPropertyName("count")] - public float? Count { get; set; } + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = false; /// - /// The number of stars to display. + /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// - [JsonPropertyName("max")] - public float? Max { get; set; } + [JsonPropertyName("colorSet")] + public ChartColorSet? ColorSet { get; set; } /// - /// The size of the stars. + /// The maximum width, in pixels, of the chart, in the `px` format. /// - [JsonPropertyName("size")] - public RatingSize? Size { get; set; } + [JsonPropertyName("maxWidth")] + public string? MaxWidth { get; set; } /// - /// The color of the stars. + /// Controls whether the chart's legend should be displayed. /// - [JsonPropertyName("color")] - public RatingColor? Color { get; set; } + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; set; } = true; /// - /// The style of the stars. + /// The data to display in the chart. /// - [JsonPropertyName("style")] - public RatingStyle? Style { get; set; } + [JsonPropertyName("data")] + public IList? Data { get; set; } + + /// + /// The value that should be displayed in the center of a Donut chart. `value` is ignored for Pie charts. + /// + [JsonPropertyName("value")] + public string? Value { get; set; } + + /// + /// Controls the color of the value displayed in the center of a Donut chart. + /// + [JsonPropertyName("valueColor")] + public ChartColor? ValueColor { get; set; } + + /// + /// Controls the thickness of the donut segments. Default is **Thick**. + /// + [JsonPropertyName("thickness")] + public DonutThickness? Thickness { get; set; } + + /// + /// Controls whether the outlines of the donut segments are displayed. + /// + [JsonPropertyName("showOutlines")] + public bool? ShowOutlines { get; set; } = true; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -8904,7 +11629,7 @@ public class Rating : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this Rating into a JSON string. + /// Serializes this DonutChart into a JSON string. /// public string Serialize() { @@ -8918,133 +11643,252 @@ public string Serialize() ); } - public Rating WithId(string value) + public DonutChart WithKey(string value) + { + this.Key = value; + return this; + } + + public DonutChart WithId(string value) { this.Id = value; return this; } - public Rating WithRequires(HostCapabilities value) + public DonutChart WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public Rating WithLang(string value) + public DonutChart WithLang(string value) { this.Lang = value; return this; } - public Rating WithIsVisible(bool value) + public DonutChart WithIsVisible(bool value) { this.IsVisible = value; return this; } - public Rating WithSeparator(bool value) + public DonutChart WithSeparator(bool value) { this.Separator = value; return this; } - public Rating WithHeight(ElementHeight value) + public DonutChart WithHeight(ElementHeight value) { this.Height = value; return this; } - public Rating WithHorizontalAlignment(HorizontalAlignment value) + public DonutChart WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public Rating WithSpacing(Spacing value) + public DonutChart WithSpacing(Spacing value) { this.Spacing = value; return this; } - public Rating WithTargetWidth(TargetWidth value) + public DonutChart WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public Rating WithIsSortKey(bool value) + public DonutChart WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public Rating WithValue(float value) + public DonutChart WithTitle(string value) + { + this.Title = value; + return this; + } + + public DonutChart WithShowTitle(bool value) + { + this.ShowTitle = value; + return this; + } + + public DonutChart WithColorSet(ChartColorSet value) + { + this.ColorSet = value; + return this; + } + + public DonutChart WithMaxWidth(string value) + { + this.MaxWidth = value; + return this; + } + + public DonutChart WithShowLegend(bool value) + { + this.ShowLegend = value; + return this; + } + + public DonutChart WithData(params DonutChartData[] value) + { + this.Data = new List(value); + return this; + } + + public DonutChart WithData(IList value) + { + this.Data = value; + return this; + } + + public DonutChart WithValue(string value) { this.Value = value; return this; } - public Rating WithCount(float value) + public DonutChart WithValueColor(ChartColor value) { - this.Count = value; + this.ValueColor = value; return this; } - public Rating WithMax(float value) + public DonutChart WithThickness(DonutThickness value) { - this.Max = value; + this.Thickness = value; return this; } - public Rating WithSize(RatingSize value) + public DonutChart WithShowOutlines(bool value) { - this.Size = value; + this.ShowOutlines = value; + return this; + } + + public DonutChart WithGridArea(string value) + { + this.GridArea = value; + return this; + } + + public DonutChart WithFallback(IUnion value) + { + this.Fallback = value; return this; } +} + +/// +/// A data point in a Donut chart. +/// +public class DonutChartData : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type DonutChartData. + /// + public static DonutChartData? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The legend of the chart. + /// + [JsonPropertyName("legend")] + public string? Legend { get; set; } + + /// + /// The value associated with the data point. + /// + [JsonPropertyName("value")] + public float? Value { get; set; } = 0; + + /// + /// The color to use for the data point. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// + [JsonPropertyName("color")] + public ChartColor? Color { get; set; } + + /// + /// Serializes this DonutChartData into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } - public Rating WithColor(RatingColor value) + public DonutChartData WithKey(string value) { - this.Color = value; + this.Key = value; return this; } - public Rating WithStyle(RatingStyle value) + public DonutChartData WithLegend(string value) { - this.Style = value; + this.Legend = value; return this; } - public Rating WithGridArea(string value) + public DonutChartData WithValue(float value) { - this.GridArea = value; + this.Value = value; return this; } - public Rating WithFallback(IUnion value) + public DonutChartData WithColor(ChartColor value) { - this.Fallback = value; + this.Color = value; return this; } } /// -/// A special type of button with an icon, title and description. +/// A pie chart. /// -public class CompoundButton : CardElement +public class PieChart : CardElement { /// - /// Deserializes a JSON string into an object of type CompoundButton. + /// Deserializes a JSON string into an object of type PieChart. /// - public static CompoundButton? Deserialize(string json) + public static PieChart? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **CompoundButton**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Chart.Pie**. /// [JsonPropertyName("type")] - public string Type { get; } = "CompoundButton"; + public string Type { get; } = "Chart.Pie"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -9056,7 +11900,7 @@ public class CompoundButton : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -9068,19 +11912,19 @@ public class CompoundButton : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -9092,7 +11936,7 @@ public class CompoundButton : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -9104,37 +11948,67 @@ public class CompoundButton : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The icon to show on the button. + /// The title of the chart. /// - [JsonPropertyName("icon")] - public IconInfo? Icon { get; set; } + [JsonPropertyName("title")] + public string? Title { get; set; } /// - /// The badge to show on the button. + /// Controls whether the chart's title should be displayed. Defaults to `false`. /// - [JsonPropertyName("badge")] - public string? Badge { get; set; } + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = false; /// - /// The title of the button. + /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// - [JsonPropertyName("title")] - public string? Title { get; set; } + [JsonPropertyName("colorSet")] + public ChartColorSet? ColorSet { get; set; } /// - /// The description text of the button. + /// The maximum width, in pixels, of the chart, in the `px` format. /// - [JsonPropertyName("description")] - public string? Description { get; set; } + [JsonPropertyName("maxWidth")] + public string? MaxWidth { get; set; } /// - /// An Action that will be invoked when the button is tapped or clicked. Action.ShowCard is not supported. + /// Controls whether the chart's legend should be displayed. /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; set; } = true; + + /// + /// The data to display in the chart. + /// + [JsonPropertyName("data")] + public IList? Data { get; set; } + + /// + /// The value that should be displayed in the center of a Donut chart. `value` is ignored for Pie charts. + /// + [JsonPropertyName("value")] + public string? Value { get; set; } + + /// + /// Controls the color of the value displayed in the center of a Donut chart. + /// + [JsonPropertyName("valueColor")] + public ChartColor? ValueColor { get; set; } + + /// + /// Controls the thickness of the donut segments. Default is **Thick**. + /// + [JsonPropertyName("thickness")] + public DonutThickness? Thickness { get; set; } + + /// + /// Controls whether the outlines of the donut segments are displayed. + /// + [JsonPropertyName("showOutlines")] + public bool? ShowOutlines { get; set; } = true; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -9149,7 +12023,7 @@ public class CompoundButton : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this CompoundButton into a JSON string. + /// Serializes this PieChart into a JSON string. /// public string Serialize() { @@ -9163,204 +12037,175 @@ public string Serialize() ); } - public CompoundButton WithId(string value) + public PieChart WithKey(string value) + { + this.Key = value; + return this; + } + + public PieChart WithId(string value) { this.Id = value; return this; } - public CompoundButton WithRequires(HostCapabilities value) + public PieChart WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public CompoundButton WithLang(string value) + public PieChart WithLang(string value) { this.Lang = value; return this; } - public CompoundButton WithIsVisible(bool value) + public PieChart WithIsVisible(bool value) { this.IsVisible = value; return this; } - public CompoundButton WithSeparator(bool value) + public PieChart WithSeparator(bool value) { this.Separator = value; return this; } - public CompoundButton WithHeight(ElementHeight value) + public PieChart WithHeight(ElementHeight value) { this.Height = value; return this; } - public CompoundButton WithHorizontalAlignment(HorizontalAlignment value) + public PieChart WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public CompoundButton WithSpacing(Spacing value) + public PieChart WithSpacing(Spacing value) { this.Spacing = value; return this; } - public CompoundButton WithTargetWidth(TargetWidth value) + public PieChart WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public CompoundButton WithIsSortKey(bool value) + public PieChart WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public CompoundButton WithIcon(IconInfo value) + public PieChart WithTitle(string value) { - this.Icon = value; + this.Title = value; return this; } - public CompoundButton WithBadge(string value) + public PieChart WithShowTitle(bool value) { - this.Badge = value; + this.ShowTitle = value; return this; } - public CompoundButton WithTitle(string value) + public PieChart WithColorSet(ChartColorSet value) { - this.Title = value; + this.ColorSet = value; return this; } - public CompoundButton WithDescription(string value) + public PieChart WithMaxWidth(string value) { - this.Description = value; + this.MaxWidth = value; return this; } - public CompoundButton WithSelectAction(Action value) + public PieChart WithShowLegend(bool value) { - this.SelectAction = value; + this.ShowLegend = value; return this; } - public CompoundButton WithGridArea(string value) + public PieChart WithData(params DonutChartData[] value) { - this.GridArea = value; + this.Data = new List(value); return this; } - public CompoundButton WithFallback(IUnion value) + public PieChart WithData(IList value) { - this.Fallback = value; + this.Data = value; return this; } -} -/// -/// Defines information about a Fluent icon and how it should be rendered. -/// -public class IconInfo : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type IconInfo. - /// - public static IconInfo? Deserialize(string json) + public PieChart WithValue(string value) { - return JsonSerializer.Deserialize(json); + this.Value = value; + return this; } - /// - /// The name of the icon to display. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// The size of the icon. - /// - [JsonPropertyName("size")] - public IconSize? Size { get; set; } - - /// - /// The style of the icon. - /// - [JsonPropertyName("style")] - public IconStyle? Style { get; set; } - - /// - /// The color of the icon. - /// - [JsonPropertyName("color")] - public TextColor? Color { get; set; } - - /// - /// Serializes this IconInfo into a JSON string. - /// - public string Serialize() + public PieChart WithValueColor(ChartColor value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.ValueColor = value; + return this; } - public IconInfo WithName(string value) + public PieChart WithThickness(DonutThickness value) { - this.Name = value; + this.Thickness = value; return this; } - public IconInfo WithSize(IconSize value) + public PieChart WithShowOutlines(bool value) { - this.Size = value; + this.ShowOutlines = value; return this; } - public IconInfo WithStyle(IconStyle value) + public PieChart WithGridArea(string value) { - this.Style = value; + this.GridArea = value; return this; } - public IconInfo WithColor(TextColor value) + public PieChart WithFallback(IUnion value) { - this.Color = value; + this.Fallback = value; return this; } } /// -/// A standalone icon element. Icons can be picked from the vast [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog). +/// A grouped vertical bar chart. /// -public class Icon : CardElement +public class GroupedVerticalBarChart : CardElement { /// - /// Deserializes a JSON string into an object of type Icon. + /// Deserializes a JSON string into an object of type GroupedVerticalBarChart. /// - public static Icon? Deserialize(string json) + public static GroupedVerticalBarChart? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Icon**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Chart.VerticalBar.Grouped**. /// [JsonPropertyName("type")] - public string Type { get; } = "Icon"; + public string Type { get; } = "Chart.VerticalBar.Grouped"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -9372,7 +12217,7 @@ public class Icon : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -9384,13 +12229,19 @@ public class Icon : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; + + /// + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -9402,7 +12253,7 @@ public class Icon : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -9414,37 +12265,91 @@ public class Icon : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The name of the icon to display. + /// The title of the chart. /// - [JsonPropertyName("name")] - public string? Name { get; set; } + [JsonPropertyName("title")] + public string? Title { get; set; } /// - /// The size of the icon. + /// Controls whether the chart's title should be displayed. Defaults to `false`. /// - [JsonPropertyName("size")] - public IconSize? Size { get; set; } + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = false; /// - /// The style of the icon. + /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// + [JsonPropertyName("colorSet")] + public ChartColorSet? ColorSet { get; set; } + + /// + /// The maximum width, in pixels, of the chart, in the `px` format. + /// + [JsonPropertyName("maxWidth")] + public string? MaxWidth { get; set; } + + /// + /// Controls whether the chart's legend should be displayed. + /// + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; set; } = true; + + /// + /// The title of the x axis. + /// + [JsonPropertyName("xAxisTitle")] + public string? XAxisTitle { get; set; } + + /// + /// The title of the y axis. + /// + [JsonPropertyName("yAxisTitle")] + public string? YAxisTitle { get; set; } + + /// + /// The color to use for all data points. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// + [JsonPropertyName("color")] + public ChartColor? Color { get; set; } + + /// + /// Controls if bars in the chart should be displayed as stacks instead of groups. + /// + /// **Note:** stacked vertical bar charts do not support custom Y ranges nor negative Y values. + /// + [JsonPropertyName("stacked")] + public bool? Stacked { get; set; } = false; + + /// + /// The data points in a series. + /// + [JsonPropertyName("data")] + public IList? Data { get; set; } + + /// + /// Controls if values should be displayed on each bar. /// - [JsonPropertyName("style")] - public IconStyle? Style { get; set; } + [JsonPropertyName("showBarValues")] + public bool? ShowBarValues { get; set; } = false; /// - /// The color of the icon. + /// The requested minimum for the Y axis range. The value used at runtime may be different to optimize visual presentation. + /// + /// `yMin` is ignored if `stacked` is set to `true`. /// - [JsonPropertyName("color")] - public TextColor? Color { get; set; } + [JsonPropertyName("yMin")] + public float? YMin { get; set; } /// - /// An Action that will be invoked when the icon is tapped or clicked. Action.ShowCard is not supported. + /// The requested maximum for the Y axis range. The value used at runtime may be different to optimize visual presentation. + /// + /// `yMax` is ignored if `stacked` is set to `true`. /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } + [JsonPropertyName("yMax")] + public float? YMax { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -9458,13 +12363,8 @@ public class Icon : CardElement [JsonPropertyName("fallback")] public IUnion? Fallback { get; set; } - public Icon(string name) - { - this.Name = name; - } - /// - /// Serializes this Icon into a JSON string. + /// Serializes this GroupedVerticalBarChart into a JSON string. /// public string Serialize() { @@ -9478,214 +12378,208 @@ public string Serialize() ); } - public Icon WithId(string value) + public GroupedVerticalBarChart WithKey(string value) + { + this.Key = value; + return this; + } + + public GroupedVerticalBarChart WithId(string value) { this.Id = value; return this; } - public Icon WithRequires(HostCapabilities value) + public GroupedVerticalBarChart WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public Icon WithLang(string value) + public GroupedVerticalBarChart WithLang(string value) { this.Lang = value; return this; } - public Icon WithIsVisible(bool value) + public GroupedVerticalBarChart WithIsVisible(bool value) { this.IsVisible = value; return this; } - public Icon WithSeparator(bool value) + public GroupedVerticalBarChart WithSeparator(bool value) { this.Separator = value; return this; } - public Icon WithHorizontalAlignment(HorizontalAlignment value) + public GroupedVerticalBarChart WithHeight(ElementHeight value) + { + this.Height = value; + return this; + } + + public GroupedVerticalBarChart WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public Icon WithSpacing(Spacing value) + public GroupedVerticalBarChart WithSpacing(Spacing value) { this.Spacing = value; return this; } - public Icon WithTargetWidth(TargetWidth value) + public GroupedVerticalBarChart WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public Icon WithIsSortKey(bool value) + public GroupedVerticalBarChart WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public Icon WithName(string value) + public GroupedVerticalBarChart WithTitle(string value) { - this.Name = value; + this.Title = value; return this; } - public Icon WithSize(IconSize value) + public GroupedVerticalBarChart WithShowTitle(bool value) { - this.Size = value; + this.ShowTitle = value; return this; } - public Icon WithStyle(IconStyle value) + public GroupedVerticalBarChart WithColorSet(ChartColorSet value) { - this.Style = value; + this.ColorSet = value; return this; } - public Icon WithColor(TextColor value) + public GroupedVerticalBarChart WithMaxWidth(string value) { - this.Color = value; + this.MaxWidth = value; return this; } - public Icon WithSelectAction(Action value) + public GroupedVerticalBarChart WithShowLegend(bool value) { - this.SelectAction = value; + this.ShowLegend = value; return this; } - public Icon WithGridArea(string value) + public GroupedVerticalBarChart WithXAxisTitle(string value) { - this.GridArea = value; + this.XAxisTitle = value; return this; } - public Icon WithFallback(IUnion value) + public GroupedVerticalBarChart WithYAxisTitle(string value) { - this.Fallback = value; + this.YAxisTitle = value; return this; } -} -/// -/// A carousel with sliding pages. -/// -public class Carousel : CardElement -{ - /// - /// Deserializes a JSON string into an object of type Carousel. - /// - public static Carousel? Deserialize(string json) + public GroupedVerticalBarChart WithColor(ChartColor value) { - return JsonSerializer.Deserialize(json); + this.Color = value; + return this; } - /// - /// Must be **Carousel**. - /// - [JsonPropertyName("type")] - public string Type { get; } = "Carousel"; - - /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). - /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } - - /// - /// The locale associated with the element. - /// - [JsonPropertyName("lang")] - public string? Lang { get; set; } + public GroupedVerticalBarChart WithStacked(bool value) + { + this.Stacked = value; + return this; + } - /// - /// Controls the visibility of the element. - /// - [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public GroupedVerticalBarChart WithData(params GroupedVerticalBarChartData[] value) + { + this.Data = new List(value); + return this; + } - /// - /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. - /// - [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public GroupedVerticalBarChart WithData(IList value) + { + this.Data = value; + return this; + } - /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. - /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public GroupedVerticalBarChart WithShowBarValues(bool value) + { + this.ShowBarValues = value; + return this; + } - /// - /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. - /// - [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public GroupedVerticalBarChart WithYMin(float value) + { + this.YMin = value; + return this; + } - /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). - /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } + public GroupedVerticalBarChart WithYMax(float value) + { + this.YMax = value; + return this; + } - /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. - /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public GroupedVerticalBarChart WithGridArea(string value) + { + this.GridArea = value; + return this; + } - /// - /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. - /// - [JsonPropertyName("bleed")] - public bool? Bleed { get; set; } + public GroupedVerticalBarChart WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} +/// +/// Represents a series of data points. +/// +public class GroupedVerticalBarChartData : SerializableObject +{ /// - /// The minimum height, in pixels, of the container, in the `px` format. + /// Deserializes a JSON string into an object of type GroupedVerticalBarChartData. /// - [JsonPropertyName("minHeight")] - public string? MinHeight { get; set; } + public static GroupedVerticalBarChartData? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } /// - /// Controls the type of animation to use to navigate between pages. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("pageAnimation")] - public CarouselPageAnimation? PageAnimation { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The area of a Layout.AreaGrid layout in which an element should be displayed. + /// The legend of the chart. /// - [JsonPropertyName("grid.area")] - public string? GridArea { get; set; } + [JsonPropertyName("legend")] + public string? Legend { get; set; } /// - /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The data points in the series. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("values")] + public IList? Values { get; set; } /// - /// The pages in the carousel. + /// The color to use for all data points in the series. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// - [JsonPropertyName("pages")] - public IList? Pages { get; set; } + [JsonPropertyName("color")] + public ChartColor? Color { get; set; } /// - /// Serializes this Carousel into a JSON string. + /// Serializes this GroupedVerticalBarChartData into a JSON string. /// public string Serialize() { @@ -9699,115 +12593,126 @@ public string Serialize() ); } - public Carousel WithId(string value) - { - this.Id = value; - return this; - } - - public Carousel WithRequires(HostCapabilities value) - { - this.Requires = value; - return this; - } - - public Carousel WithLang(string value) - { - this.Lang = value; - return this; - } - - public Carousel WithIsVisible(bool value) - { - this.IsVisible = value; - return this; - } - - public Carousel WithSeparator(bool value) - { - this.Separator = value; - return this; - } - - public Carousel WithHeight(ElementHeight value) + public GroupedVerticalBarChartData WithKey(string value) { - this.Height = value; + this.Key = value; return this; } - public Carousel WithSpacing(Spacing value) + public GroupedVerticalBarChartData WithLegend(string value) { - this.Spacing = value; + this.Legend = value; return this; } - public Carousel WithTargetWidth(TargetWidth value) + public GroupedVerticalBarChartData WithValues(params BarChartDataValue[] value) { - this.TargetWidth = value; + this.Values = new List(value); return this; } - public Carousel WithIsSortKey(bool value) + public GroupedVerticalBarChartData WithValues(IList value) { - this.IsSortKey = value; + this.Values = value; return this; } - public Carousel WithBleed(bool value) + public GroupedVerticalBarChartData WithColor(ChartColor value) { - this.Bleed = value; + this.Color = value; return this; } +} - public Carousel WithMinHeight(string value) +/// +/// A single data point in a bar chart. +/// +public class BarChartDataValue : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type BarChartDataValue. + /// + public static BarChartDataValue? Deserialize(string json) { - this.MinHeight = value; - return this; + return JsonSerializer.Deserialize(json); } - public Carousel WithPageAnimation(CarouselPageAnimation value) + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The x axis value of the data point. + /// + [JsonPropertyName("x")] + public string? X { get; set; } + + /// + /// The y axis value of the data point. + /// + [JsonPropertyName("y")] + public float? Y { get; set; } = 0; + + /// + /// Serializes this BarChartDataValue into a JSON string. + /// + public string Serialize() { - this.PageAnimation = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public Carousel WithGridArea(string value) + public BarChartDataValue WithKey(string value) { - this.GridArea = value; + this.Key = value; return this; } - public Carousel WithFallback(IUnion value) + public BarChartDataValue WithX(string value) { - this.Fallback = value; + this.X = value; return this; } - public Carousel WithPages(params IList value) + public BarChartDataValue WithY(float value) { - this.Pages = value; + this.Y = value; return this; } } /// -/// A badge element to show an icon and/or text in a compact form over a colored background. +/// A vertical bar chart. /// -public class Badge : CardElement +public class VerticalBarChart : CardElement { /// - /// Deserializes a JSON string into an object of type Badge. + /// Deserializes a JSON string into an object of type VerticalBarChart. /// - public static Badge? Deserialize(string json) + public static VerticalBarChart? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Badge**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Chart.VerticalBar**. /// [JsonPropertyName("type")] - public string Type { get; } = "Badge"; + public string Type { get; } = "Chart.VerticalBar"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -9819,7 +12724,7 @@ public class Badge : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -9831,19 +12736,19 @@ public class Badge : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -9855,7 +12760,7 @@ public class Badge : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -9867,55 +12772,79 @@ public class Badge : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The text to display. + /// The title of the chart. /// - [JsonPropertyName("text")] - public string? Text { get; set; } + [JsonPropertyName("title")] + public string? Title { get; set; } /// - /// The name of an icon from the [Adaptive Card icon catalog](https://adaptivecards.microsoft.com/?topic=icon-catalog) to display, in the `[,regular|filled]` format. If the style is not specified, the regular style is used. + /// Controls whether the chart's title should be displayed. Defaults to `false`. /// - [JsonPropertyName("icon")] - public string? Icon { get; set; } + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = false; /// - /// Controls the position of the icon. + /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// - [JsonPropertyName("iconPosition")] - public BadgeIconPosition? IconPosition { get; set; } + [JsonPropertyName("colorSet")] + public ChartColorSet? ColorSet { get; set; } /// - /// Controls the strength of the background color. + /// The maximum width, in pixels, of the chart, in the `px` format. /// - [JsonPropertyName("appearance")] - public BadgeAppearance? Appearance { get; set; } + [JsonPropertyName("maxWidth")] + public string? MaxWidth { get; set; } /// - /// The size of the badge. + /// Controls whether the chart's legend should be displayed. /// - [JsonPropertyName("size")] - public BadgeSize? Size { get; set; } + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; set; } = true; /// - /// Controls the shape of the badge. + /// The title of the x axis. /// - [JsonPropertyName("shape")] - public BadgeShape? Shape { get; set; } + [JsonPropertyName("xAxisTitle")] + public string? XAxisTitle { get; set; } /// - /// The style of the badge. + /// The title of the y axis. /// - [JsonPropertyName("style")] - public BadgeStyle? Style { get; set; } + [JsonPropertyName("yAxisTitle")] + public string? YAxisTitle { get; set; } /// - /// Controls the tooltip text to display when the badge is hovered over. + /// The data to display in the chart. /// - [JsonPropertyName("tooltip")] - public string? Tooltip { get; set; } + [JsonPropertyName("data")] + public IList? Data { get; set; } + + /// + /// The color to use for all data points. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// + [JsonPropertyName("color")] + public ChartColor? Color { get; set; } + + /// + /// Controls if the bar values should be displayed. + /// + [JsonPropertyName("showBarValues")] + public bool? ShowBarValues { get; set; } = false; + + /// + /// The requested minimum for the Y axis range. The value used at runtime may be different to optimize visual presentation. + /// + [JsonPropertyName("yMin")] + public float? YMin { get; set; } + + /// + /// The requested maximum for the Y axis range. The value used at runtime may be different to optimize visual presentation. + /// + [JsonPropertyName("yMax")] + public float? YMax { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -9930,7 +12859,7 @@ public class Badge : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this Badge into a JSON string. + /// Serializes this VerticalBarChart into a JSON string. /// public string Serialize() { @@ -9944,145 +12873,264 @@ public string Serialize() ); } - public Badge WithId(string value) + public VerticalBarChart WithKey(string value) + { + this.Key = value; + return this; + } + + public VerticalBarChart WithId(string value) { this.Id = value; return this; } - public Badge WithRequires(HostCapabilities value) + public VerticalBarChart WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public Badge WithLang(string value) + public VerticalBarChart WithLang(string value) { this.Lang = value; return this; } - public Badge WithIsVisible(bool value) + public VerticalBarChart WithIsVisible(bool value) { this.IsVisible = value; return this; } - public Badge WithSeparator(bool value) + public VerticalBarChart WithSeparator(bool value) { this.Separator = value; return this; } - public Badge WithHeight(ElementHeight value) + public VerticalBarChart WithHeight(ElementHeight value) { this.Height = value; return this; } - public Badge WithHorizontalAlignment(HorizontalAlignment value) + public VerticalBarChart WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public Badge WithSpacing(Spacing value) + public VerticalBarChart WithSpacing(Spacing value) { this.Spacing = value; return this; } - public Badge WithTargetWidth(TargetWidth value) + public VerticalBarChart WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public Badge WithIsSortKey(bool value) + public VerticalBarChart WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public Badge WithText(string value) + public VerticalBarChart WithTitle(string value) { - this.Text = value; + this.Title = value; return this; } - public Badge WithIcon(string value) + public VerticalBarChart WithShowTitle(bool value) { - this.Icon = value; + this.ShowTitle = value; return this; } - public Badge WithIconPosition(BadgeIconPosition value) + public VerticalBarChart WithColorSet(ChartColorSet value) { - this.IconPosition = value; + this.ColorSet = value; return this; } - public Badge WithAppearance(BadgeAppearance value) + public VerticalBarChart WithMaxWidth(string value) { - this.Appearance = value; + this.MaxWidth = value; + return this; + } + + public VerticalBarChart WithShowLegend(bool value) + { + this.ShowLegend = value; + return this; + } + + public VerticalBarChart WithXAxisTitle(string value) + { + this.XAxisTitle = value; + return this; + } + + public VerticalBarChart WithYAxisTitle(string value) + { + this.YAxisTitle = value; + return this; + } + + public VerticalBarChart WithData(params VerticalBarChartDataValue[] value) + { + this.Data = new List(value); + return this; + } + + public VerticalBarChart WithData(IList value) + { + this.Data = value; + return this; + } + + public VerticalBarChart WithColor(ChartColor value) + { + this.Color = value; + return this; + } + + public VerticalBarChart WithShowBarValues(bool value) + { + this.ShowBarValues = value; + return this; + } + + public VerticalBarChart WithYMin(float value) + { + this.YMin = value; + return this; + } + + public VerticalBarChart WithYMax(float value) + { + this.YMax = value; + return this; + } + + public VerticalBarChart WithGridArea(string value) + { + this.GridArea = value; return this; } - public Badge WithSize(BadgeSize value) - { - this.Size = value; - return this; - } + public VerticalBarChart WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} + +/// +/// Represents a data point in a vertical bar chart. +/// +public class VerticalBarChartDataValue : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type VerticalBarChartDataValue. + /// + public static VerticalBarChartDataValue? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The x axis value of the data point. + /// + [JsonPropertyName("x")] + public IUnion? X { get; set; } + + /// + /// The y axis value of the data point. + /// + [JsonPropertyName("y")] + public float? Y { get; set; } = 0; + + /// + /// The color to use for the bar associated with the data point. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// + [JsonPropertyName("color")] + public ChartColor? Color { get; set; } - public Badge WithShape(BadgeShape value) + /// + /// Serializes this VerticalBarChartDataValue into a JSON string. + /// + public string Serialize() { - this.Shape = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public Badge WithStyle(BadgeStyle value) + public VerticalBarChartDataValue WithKey(string value) { - this.Style = value; + this.Key = value; return this; } - public Badge WithTooltip(string value) + public VerticalBarChartDataValue WithX(IUnion value) { - this.Tooltip = value; + this.X = value; return this; } - public Badge WithGridArea(string value) + public VerticalBarChartDataValue WithY(float value) { - this.GridArea = value; + this.Y = value; return this; } - public Badge WithFallback(IUnion value) + public VerticalBarChartDataValue WithColor(ChartColor value) { - this.Fallback = value; + this.Color = value; return this; } } /// -/// A donut chart. +/// A horizontal bar chart. /// -public class DonutChart : CardElement +public class HorizontalBarChart : CardElement { /// - /// Deserializes a JSON string into an object of type DonutChart. + /// Deserializes a JSON string into an object of type HorizontalBarChart. /// - public static DonutChart? Deserialize(string json) + public static HorizontalBarChart? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Chart.Donut**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Chart.HorizontalBar**. /// [JsonPropertyName("type")] - public string Type { get; } = "Chart.Donut"; + public string Type { get; } = "Chart.HorizontalBar"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -10094,7 +13142,7 @@ public class DonutChart : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -10106,19 +13154,19 @@ public class DonutChart : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -10130,7 +13178,7 @@ public class DonutChart : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -10142,7 +13190,7 @@ public class DonutChart : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// /// The title of the chart. @@ -10150,6 +13198,12 @@ public class DonutChart : CardElement [JsonPropertyName("title")] public string? Title { get; set; } + /// + /// Controls whether the chart's title should be displayed. Defaults to `false`. + /// + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = false; + /// /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// @@ -10157,10 +13211,46 @@ public class DonutChart : CardElement public ChartColorSet? ColorSet { get; set; } /// - /// The data to display in the chart. + /// The maximum width, in pixels, of the chart, in the `px` format. + /// + [JsonPropertyName("maxWidth")] + public string? MaxWidth { get; set; } + + /// + /// Controls whether the chart's legend should be displayed. + /// + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; set; } = true; + + /// + /// The title of the x axis. + /// + [JsonPropertyName("xAxisTitle")] + public string? XAxisTitle { get; set; } + + /// + /// The title of the y axis. + /// + [JsonPropertyName("yAxisTitle")] + public string? YAxisTitle { get; set; } + + /// + /// The color to use for all data points. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// + [JsonPropertyName("color")] + public ChartColor? Color { get; set; } + + /// + /// The data points in the chart. /// [JsonPropertyName("data")] - public IList? Data { get; set; } + public IList? Data { get; set; } + + /// + /// Controls how the chart should be visually laid out. + /// + [JsonPropertyName("displayMode")] + public HorizontalBarChartDisplayMode? DisplayMode { get; set; } = HorizontalBarChartDisplayMode.AbsoluteWithAxis; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -10175,7 +13265,7 @@ public class DonutChart : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this DonutChart into a JSON string. + /// Serializes this HorizontalBarChart into a JSON string. /// public string Serialize() { @@ -10189,91 +13279,145 @@ public string Serialize() ); } - public DonutChart WithId(string value) + public HorizontalBarChart WithKey(string value) + { + this.Key = value; + return this; + } + + public HorizontalBarChart WithId(string value) { this.Id = value; return this; } - public DonutChart WithRequires(HostCapabilities value) + public HorizontalBarChart WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public DonutChart WithLang(string value) + public HorizontalBarChart WithLang(string value) { this.Lang = value; return this; } - public DonutChart WithIsVisible(bool value) + public HorizontalBarChart WithIsVisible(bool value) { this.IsVisible = value; return this; } - public DonutChart WithSeparator(bool value) + public HorizontalBarChart WithSeparator(bool value) { this.Separator = value; return this; } - public DonutChart WithHeight(ElementHeight value) + public HorizontalBarChart WithHeight(ElementHeight value) { this.Height = value; return this; } - public DonutChart WithHorizontalAlignment(HorizontalAlignment value) + public HorizontalBarChart WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public DonutChart WithSpacing(Spacing value) + public HorizontalBarChart WithSpacing(Spacing value) { this.Spacing = value; return this; } - public DonutChart WithTargetWidth(TargetWidth value) + public HorizontalBarChart WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public DonutChart WithIsSortKey(bool value) + public HorizontalBarChart WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public DonutChart WithTitle(string value) + public HorizontalBarChart WithTitle(string value) { this.Title = value; return this; } - public DonutChart WithColorSet(ChartColorSet value) + public HorizontalBarChart WithShowTitle(bool value) + { + this.ShowTitle = value; + return this; + } + + public HorizontalBarChart WithColorSet(ChartColorSet value) { this.ColorSet = value; return this; } - public DonutChart WithData(params IList value) + public HorizontalBarChart WithMaxWidth(string value) + { + this.MaxWidth = value; + return this; + } + + public HorizontalBarChart WithShowLegend(bool value) + { + this.ShowLegend = value; + return this; + } + + public HorizontalBarChart WithXAxisTitle(string value) + { + this.XAxisTitle = value; + return this; + } + + public HorizontalBarChart WithYAxisTitle(string value) + { + this.YAxisTitle = value; + return this; + } + + public HorizontalBarChart WithColor(ChartColor value) + { + this.Color = value; + return this; + } + + public HorizontalBarChart WithData(params HorizontalBarChartDataValue[] value) + { + this.Data = new List(value); + return this; + } + + public HorizontalBarChart WithData(IList value) { this.Data = value; return this; } - public DonutChart WithGridArea(string value) + public HorizontalBarChart WithDisplayMode(HorizontalBarChartDisplayMode value) + { + this.DisplayMode = value; + return this; + } + + public HorizontalBarChart WithGridArea(string value) { this.GridArea = value; return this; } - public DonutChart WithFallback(IUnion value) + public HorizontalBarChart WithFallback(IUnion value) { this.Fallback = value; return this; @@ -10281,38 +13425,44 @@ public DonutChart WithFallback(IUnion value) } /// -/// A data point in a Donut chart. +/// Represents a single data point in a horizontal bar chart. /// -public class DonutChartData : SerializableObject +public class HorizontalBarChartDataValue : SerializableObject { /// - /// Deserializes a JSON string into an object of type DonutChartData. + /// Deserializes a JSON string into an object of type HorizontalBarChartDataValue. /// - public static DonutChartData? Deserialize(string json) + public static HorizontalBarChartDataValue? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The legend of the chart. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("legend")] - public string? Legend { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The value associated with the data point. + /// The x axis value of the data point. /// - [JsonPropertyName("value")] - public float? Value { get; set; } + [JsonPropertyName("x")] + public string? X { get; set; } /// - /// The color to use for the data point. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// The y axis value of the data point. + /// + [JsonPropertyName("y")] + public float? Y { get; set; } = 0; + + /// + /// The color of the bar associated with the data point. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// [JsonPropertyName("color")] public ChartColor? Color { get; set; } /// - /// Serializes this DonutChartData into a JSON string. + /// Serializes this HorizontalBarChartDataValue into a JSON string. /// public string Serialize() { @@ -10326,19 +13476,25 @@ public string Serialize() ); } - public DonutChartData WithLegend(string value) + public HorizontalBarChartDataValue WithKey(string value) { - this.Legend = value; + this.Key = value; return this; } - public DonutChartData WithValue(float value) + public HorizontalBarChartDataValue WithX(string value) { - this.Value = value; + this.X = value; return this; } - public DonutChartData WithColor(ChartColor value) + public HorizontalBarChartDataValue WithY(float value) + { + this.Y = value; + return this; + } + + public HorizontalBarChartDataValue WithColor(ChartColor value) { this.Color = value; return this; @@ -10346,23 +13502,29 @@ public DonutChartData WithColor(ChartColor value) } /// -/// A pie chart. +/// A stacked horizontal bar chart. /// -public class PieChart : CardElement +public class StackedHorizontalBarChart : CardElement { /// - /// Deserializes a JSON string into an object of type PieChart. + /// Deserializes a JSON string into an object of type StackedHorizontalBarChart. /// - public static PieChart? Deserialize(string json) + public static StackedHorizontalBarChart? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Chart.Pie**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Chart.HorizontalBar.Stacked**. /// [JsonPropertyName("type")] - public string Type { get; } = "Chart.Pie"; + public string Type { get; } = "Chart.HorizontalBar.Stacked"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -10374,7 +13536,7 @@ public class PieChart : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -10386,19 +13548,19 @@ public class PieChart : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -10410,7 +13572,7 @@ public class PieChart : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -10422,7 +13584,7 @@ public class PieChart : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// /// The title of the chart. @@ -10430,17 +13592,53 @@ public class PieChart : CardElement [JsonPropertyName("title")] public string? Title { get; set; } + /// + /// Controls whether the chart's title should be displayed. Defaults to `false`. + /// + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = false; + /// /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// [JsonPropertyName("colorSet")] public ChartColorSet? ColorSet { get; set; } + /// + /// The maximum width, in pixels, of the chart, in the `px` format. + /// + [JsonPropertyName("maxWidth")] + public string? MaxWidth { get; set; } + + /// + /// Controls whether the chart's legend should be displayed. + /// + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; set; } = true; + + /// + /// The title of the x axis. + /// + [JsonPropertyName("xAxisTitle")] + public string? XAxisTitle { get; set; } + + /// + /// The title of the y axis. + /// + [JsonPropertyName("yAxisTitle")] + public string? YAxisTitle { get; set; } + + /// + /// The color to use for all data points. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// + [JsonPropertyName("color")] + public ChartColor? Color { get; set; } + /// /// The data to display in the chart. /// [JsonPropertyName("data")] - public IList? Data { get; set; } + public IList? Data { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -10455,7 +13653,7 @@ public class PieChart : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this PieChart into a JSON string. + /// Serializes this StackedHorizontalBarChart into a JSON string. /// public string Serialize() { @@ -10469,91 +13667,139 @@ public string Serialize() ); } - public PieChart WithId(string value) + public StackedHorizontalBarChart WithKey(string value) + { + this.Key = value; + return this; + } + + public StackedHorizontalBarChart WithId(string value) { this.Id = value; return this; } - public PieChart WithRequires(HostCapabilities value) + public StackedHorizontalBarChart WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public PieChart WithLang(string value) + public StackedHorizontalBarChart WithLang(string value) { this.Lang = value; return this; } - public PieChart WithIsVisible(bool value) + public StackedHorizontalBarChart WithIsVisible(bool value) { this.IsVisible = value; return this; } - public PieChart WithSeparator(bool value) + public StackedHorizontalBarChart WithSeparator(bool value) { this.Separator = value; return this; } - public PieChart WithHeight(ElementHeight value) + public StackedHorizontalBarChart WithHeight(ElementHeight value) { this.Height = value; return this; } - public PieChart WithHorizontalAlignment(HorizontalAlignment value) + public StackedHorizontalBarChart WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public PieChart WithSpacing(Spacing value) + public StackedHorizontalBarChart WithSpacing(Spacing value) { this.Spacing = value; return this; } - public PieChart WithTargetWidth(TargetWidth value) + public StackedHorizontalBarChart WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public PieChart WithIsSortKey(bool value) + public StackedHorizontalBarChart WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public PieChart WithTitle(string value) + public StackedHorizontalBarChart WithTitle(string value) { this.Title = value; return this; } - public PieChart WithColorSet(ChartColorSet value) + public StackedHorizontalBarChart WithShowTitle(bool value) + { + this.ShowTitle = value; + return this; + } + + public StackedHorizontalBarChart WithColorSet(ChartColorSet value) { this.ColorSet = value; return this; } - public PieChart WithData(params IList value) + public StackedHorizontalBarChart WithMaxWidth(string value) + { + this.MaxWidth = value; + return this; + } + + public StackedHorizontalBarChart WithShowLegend(bool value) + { + this.ShowLegend = value; + return this; + } + + public StackedHorizontalBarChart WithXAxisTitle(string value) + { + this.XAxisTitle = value; + return this; + } + + public StackedHorizontalBarChart WithYAxisTitle(string value) + { + this.YAxisTitle = value; + return this; + } + + public StackedHorizontalBarChart WithColor(ChartColor value) + { + this.Color = value; + return this; + } + + public StackedHorizontalBarChart WithData(params StackedHorizontalBarChartData[] value) + { + this.Data = new List(value); + return this; + } + + public StackedHorizontalBarChart WithData(IList value) { this.Data = value; return this; } - public PieChart WithGridArea(string value) + public StackedHorizontalBarChart WithGridArea(string value) { this.GridArea = value; return this; } - public PieChart WithFallback(IUnion value) + public StackedHorizontalBarChart WithFallback(IUnion value) { this.Fallback = value; return this; @@ -10561,23 +13807,177 @@ public PieChart WithFallback(IUnion value) } /// -/// A grouped vertical bar chart. +/// Defines the collection of data series to display in as a stacked horizontal bar chart. /// -public class GroupedVerticalBarChart : CardElement +public class StackedHorizontalBarChartData : SerializableObject { /// - /// Deserializes a JSON string into an object of type GroupedVerticalBarChart. + /// Deserializes a JSON string into an object of type StackedHorizontalBarChartData. /// - public static GroupedVerticalBarChart? Deserialize(string json) + public static StackedHorizontalBarChartData? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Chart.VerticalBar.Grouped**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The title of the series. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The data points in the series. + /// + [JsonPropertyName("data")] + public IList? Data { get; set; } + + /// + /// Serializes this StackedHorizontalBarChartData into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public StackedHorizontalBarChartData WithKey(string value) + { + this.Key = value; + return this; + } + + public StackedHorizontalBarChartData WithTitle(string value) + { + this.Title = value; + return this; + } + + public StackedHorizontalBarChartData WithData(params StackedHorizontalBarChartDataPoint[] value) + { + this.Data = new List(value); + return this; + } + + public StackedHorizontalBarChartData WithData(IList value) + { + this.Data = value; + return this; + } +} + +/// +/// A data point in a series. +/// +public class StackedHorizontalBarChartDataPoint : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type StackedHorizontalBarChartDataPoint. + /// + public static StackedHorizontalBarChartDataPoint? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The legend associated with the data point. + /// + [JsonPropertyName("legend")] + public string? Legend { get; set; } + + /// + /// The value of the data point. + /// + [JsonPropertyName("value")] + public float? Value { get; set; } = 0; + + /// + /// The color to use to render the bar associated with the data point. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// + [JsonPropertyName("color")] + public ChartColor? Color { get; set; } + + /// + /// Serializes this StackedHorizontalBarChartDataPoint into a JSON string. + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } + + public StackedHorizontalBarChartDataPoint WithKey(string value) + { + this.Key = value; + return this; + } + + public StackedHorizontalBarChartDataPoint WithLegend(string value) + { + this.Legend = value; + return this; + } + + public StackedHorizontalBarChartDataPoint WithValue(float value) + { + this.Value = value; + return this; + } + + public StackedHorizontalBarChartDataPoint WithColor(ChartColor value) + { + this.Color = value; + return this; + } +} + +/// +/// A line chart. +/// +public class LineChart : CardElement +{ + /// + /// Deserializes a JSON string into an object of type LineChart. + /// + public static LineChart? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Chart.Line**. /// [JsonPropertyName("type")] - public string Type { get; } = "Chart.VerticalBar.Grouped"; + public string Type { get; } = "Chart.Line"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -10589,7 +13989,7 @@ public class GroupedVerticalBarChart : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -10601,19 +14001,19 @@ public class GroupedVerticalBarChart : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -10625,7 +14025,7 @@ public class GroupedVerticalBarChart : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -10637,7 +14037,7 @@ public class GroupedVerticalBarChart : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// /// The title of the chart. @@ -10645,12 +14045,30 @@ public class GroupedVerticalBarChart : CardElement [JsonPropertyName("title")] public string? Title { get; set; } + /// + /// Controls whether the chart's title should be displayed. Defaults to `false`. + /// + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = false; + /// /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// [JsonPropertyName("colorSet")] public ChartColorSet? ColorSet { get; set; } + /// + /// The maximum width, in pixels, of the chart, in the `px` format. + /// + [JsonPropertyName("maxWidth")] + public string? MaxWidth { get; set; } + + /// + /// Controls whether the chart's legend should be displayed. + /// + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; set; } = true; + /// /// The title of the x axis. /// @@ -10670,37 +14088,19 @@ public class GroupedVerticalBarChart : CardElement public ChartColor? Color { get; set; } /// - /// Controls if bars in the chart should be displayed as stacks instead of groups. - /// - /// **Note:** stacked vertical bar charts do not support custom Y ranges nor negative Y values. - /// - [JsonPropertyName("stacked")] - public bool? Stacked { get; set; } - - /// - /// The data points in a series. + /// The data point series in the line chart. /// [JsonPropertyName("data")] - public IList? Data { get; set; } - - /// - /// Controls if values should be displayed on each bar. - /// - [JsonPropertyName("showBarValues")] - public bool? ShowBarValues { get; set; } + public IList? Data { get; set; } /// - /// The requested minimum for the Y axis range. The value used at runtime may be different to optimize visual presentation. - /// - /// `yMin` is ignored if `stacked` is set to `true`. + /// The maximum y range. /// [JsonPropertyName("yMin")] public float? YMin { get; set; } /// - /// The requested maximum for the Y axis range. The value used at runtime may be different to optimize visual presentation. - /// - /// `yMax` is ignored if `stacked` is set to `true`. + /// The minimum y range. /// [JsonPropertyName("yMax")] public float? YMax { get; set; } @@ -10718,7 +14118,7 @@ public class GroupedVerticalBarChart : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this GroupedVerticalBarChart into a JSON string. + /// Serializes this LineChart into a JSON string. /// public string Serialize() { @@ -10732,133 +14132,151 @@ public string Serialize() ); } - public GroupedVerticalBarChart WithId(string value) + public LineChart WithKey(string value) + { + this.Key = value; + return this; + } + + public LineChart WithId(string value) { this.Id = value; return this; } - public GroupedVerticalBarChart WithRequires(HostCapabilities value) + public LineChart WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public GroupedVerticalBarChart WithLang(string value) + public LineChart WithLang(string value) { this.Lang = value; return this; } - public GroupedVerticalBarChart WithIsVisible(bool value) + public LineChart WithIsVisible(bool value) { this.IsVisible = value; return this; } - public GroupedVerticalBarChart WithSeparator(bool value) + public LineChart WithSeparator(bool value) { this.Separator = value; return this; } - public GroupedVerticalBarChart WithHeight(ElementHeight value) + public LineChart WithHeight(ElementHeight value) { this.Height = value; return this; } - public GroupedVerticalBarChart WithHorizontalAlignment(HorizontalAlignment value) + public LineChart WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public GroupedVerticalBarChart WithSpacing(Spacing value) + public LineChart WithSpacing(Spacing value) { this.Spacing = value; return this; } - public GroupedVerticalBarChart WithTargetWidth(TargetWidth value) + public LineChart WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public GroupedVerticalBarChart WithIsSortKey(bool value) + public LineChart WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public GroupedVerticalBarChart WithTitle(string value) + public LineChart WithTitle(string value) { this.Title = value; return this; } - public GroupedVerticalBarChart WithColorSet(ChartColorSet value) + public LineChart WithShowTitle(bool value) + { + this.ShowTitle = value; + return this; + } + + public LineChart WithColorSet(ChartColorSet value) { this.ColorSet = value; return this; } - public GroupedVerticalBarChart WithXAxisTitle(string value) + public LineChart WithMaxWidth(string value) { - this.XAxisTitle = value; + this.MaxWidth = value; return this; } - public GroupedVerticalBarChart WithYAxisTitle(string value) + public LineChart WithShowLegend(bool value) { - this.YAxisTitle = value; + this.ShowLegend = value; return this; } - public GroupedVerticalBarChart WithColor(ChartColor value) + public LineChart WithXAxisTitle(string value) { - this.Color = value; + this.XAxisTitle = value; return this; } - public GroupedVerticalBarChart WithStacked(bool value) + public LineChart WithYAxisTitle(string value) { - this.Stacked = value; + this.YAxisTitle = value; return this; } - public GroupedVerticalBarChart WithData(params IList value) + public LineChart WithColor(ChartColor value) { - this.Data = value; + this.Color = value; return this; } - public GroupedVerticalBarChart WithShowBarValues(bool value) + public LineChart WithData(params LineChartData[] value) { - this.ShowBarValues = value; + this.Data = new List(value); return this; } - public GroupedVerticalBarChart WithYMin(float value) + public LineChart WithData(IList value) + { + this.Data = value; + return this; + } + + public LineChart WithYMin(float value) { this.YMin = value; return this; } - public GroupedVerticalBarChart WithYMax(float value) + public LineChart WithYMax(float value) { this.YMax = value; return this; } - public GroupedVerticalBarChart WithGridArea(string value) + public LineChart WithGridArea(string value) { this.GridArea = value; return this; } - public GroupedVerticalBarChart WithFallback(IUnion value) + public LineChart WithFallback(IUnion value) { this.Fallback = value; return this; @@ -10866,18 +14284,24 @@ public GroupedVerticalBarChart WithFallback(IUnion } /// -/// Represents a series of data points. +/// Represents a collection of data points series in a line chart. /// -public class GroupedVerticalBarChartData : SerializableObject +public class LineChartData : SerializableObject { /// - /// Deserializes a JSON string into an object of type GroupedVerticalBarChartData. + /// Deserializes a JSON string into an object of type LineChartData. /// - public static GroupedVerticalBarChartData? Deserialize(string json) + public static LineChartData? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + /// /// The legend of the chart. /// @@ -10888,16 +14312,16 @@ public class GroupedVerticalBarChartData : SerializableObject /// The data points in the series. /// [JsonPropertyName("values")] - public IList? Values { get; set; } + public IList? Values { get; set; } /// - /// The color to use for all data points in the series. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// The color all data points in the series. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// [JsonPropertyName("color")] public ChartColor? Color { get; set; } /// - /// Serializes this GroupedVerticalBarChartData into a JSON string. + /// Serializes this LineChartData into a JSON string. /// public string Serialize() { @@ -10911,19 +14335,31 @@ public string Serialize() ); } - public GroupedVerticalBarChartData WithLegend(string value) + public LineChartData WithKey(string value) + { + this.Key = value; + return this; + } + + public LineChartData WithLegend(string value) { this.Legend = value; return this; } - public GroupedVerticalBarChartData WithValues(params IList value) + public LineChartData WithValues(params LineChartValue[] value) + { + this.Values = new List(value); + return this; + } + + public LineChartData WithValues(IList value) { this.Values = value; return this; } - public GroupedVerticalBarChartData WithColor(ChartColor value) + public LineChartData WithColor(ChartColor value) { this.Color = value; return this; @@ -10931,32 +14367,42 @@ public GroupedVerticalBarChartData WithColor(ChartColor value) } /// -/// A single data point in a bar chart. +/// Represents a single data point in a line chart. /// -public class BarChartDataValue : SerializableObject +public class LineChartValue : SerializableObject { /// - /// Deserializes a JSON string into an object of type BarChartDataValue. + /// Deserializes a JSON string into an object of type LineChartValue. /// - public static BarChartDataValue? Deserialize(string json) + public static LineChartValue? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + /// /// The x axis value of the data point. + /// + /// If all x values of the x [Chart.Line](https://adaptivecards.microsoft.com/?topic=Chart.Line) are expressed as a number, or if all x values are expressed as a date string in the `YYYY-MM-DD` format, the chart will be rendered as a time series chart, i.e. x axis values will span across the minimum x value to maximum x value range. + /// + /// Otherwise, if x values are represented as a mix of numbers and strings or if at least one x value isn't in the `YYYY-MM-DD` format, the chart will be rendered as a categorical chart, i.e. x axis values will be displayed as categories. /// [JsonPropertyName("x")] - public string? X { get; set; } + public IUnion? X { get; set; } /// /// The y axis value of the data point. /// [JsonPropertyName("y")] - public float? Y { get; set; } + public float? Y { get; set; } = 0; /// - /// Serializes this BarChartDataValue into a JSON string. + /// Serializes this LineChartValue into a JSON string. /// public string Serialize() { @@ -10970,13 +14416,19 @@ public string Serialize() ); } - public BarChartDataValue WithX(string value) + public LineChartValue WithKey(string value) + { + this.Key = value; + return this; + } + + public LineChartValue WithX(IUnion value) { this.X = value; return this; } - public BarChartDataValue WithY(float value) + public LineChartValue WithY(float value) { this.Y = value; return this; @@ -10984,23 +14436,29 @@ public BarChartDataValue WithY(float value) } /// -/// A vertical bar chart. +/// A gauge chart. /// -public class VerticalBarChart : CardElement +public class GaugeChart : CardElement { /// - /// Deserializes a JSON string into an object of type VerticalBarChart. + /// Deserializes a JSON string into an object of type GaugeChart. /// - public static VerticalBarChart? Deserialize(string json) + public static GaugeChart? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Chart.VerticalBar**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Chart.Gauge**. /// [JsonPropertyName("type")] - public string Type { get; } = "Chart.VerticalBar"; + public string Type { get; } = "Chart.Gauge"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -11012,7 +14470,7 @@ public class VerticalBarChart : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -11024,19 +14482,19 @@ public class VerticalBarChart : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -11048,7 +14506,7 @@ public class VerticalBarChart : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -11060,7 +14518,7 @@ public class VerticalBarChart : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// /// The title of the chart. @@ -11068,6 +14526,12 @@ public class VerticalBarChart : CardElement [JsonPropertyName("title")] public string? Title { get; set; } + /// + /// Controls whether the chart's title should be displayed. Defaults to `false`. + /// + [JsonPropertyName("showTitle")] + public bool? ShowTitle { get; set; } = false; + /// /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// @@ -11075,46 +14539,70 @@ public class VerticalBarChart : CardElement public ChartColorSet? ColorSet { get; set; } /// - /// The title of the x axis. + /// The maximum width, in pixels, of the chart, in the `px` format. /// - [JsonPropertyName("xAxisTitle")] - public string? XAxisTitle { get; set; } + [JsonPropertyName("maxWidth")] + public string? MaxWidth { get; set; } /// - /// The title of the y axis. + /// Controls whether the chart's legend should be displayed. /// - [JsonPropertyName("yAxisTitle")] - public string? YAxisTitle { get; set; } + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; set; } = true; /// - /// The data to display in the chart. + /// The minimum value of the gauge. /// - [JsonPropertyName("data")] - public IList? Data { get; set; } + [JsonPropertyName("min")] + public float? Min { get; set; } = 0; /// - /// The color to use for all data points. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// The maximum value of the gauge. /// - [JsonPropertyName("color")] - public ChartColor? Color { get; set; } + [JsonPropertyName("max")] + public float? Max { get; set; } /// - /// Controls if the bar values should be displayed. + /// The sub-label of the gauge. /// - [JsonPropertyName("showBarValues")] - public bool? ShowBarValues { get; set; } + [JsonPropertyName("subLabel")] + public string? SubLabel { get; set; } /// - /// The requested minimum for the Y axis range. The value used at runtime may be different to optimize visual presentation. + /// Controls whether the min/max values should be displayed. /// - [JsonPropertyName("yMin")] - public float? YMin { get; set; } + [JsonPropertyName("showMinMax")] + public bool? ShowMinMax { get; set; } = true; /// - /// The requested maximum for the Y axis range. The value used at runtime may be different to optimize visual presentation. + /// Controls whether the gauge's needle is displayed. Default is **true**. /// - [JsonPropertyName("yMax")] - public float? YMax { get; set; } + [JsonPropertyName("showNeedle")] + public bool? ShowNeedle { get; set; } = true; + + /// + /// Controls whether the outlines of the gauge segments are displayed. + /// + [JsonPropertyName("showOutlines")] + public bool? ShowOutlines { get; set; } = true; + + /// + /// The segments to display in the gauge. + /// + [JsonPropertyName("segments")] + public IList? Segments { get; set; } + + /// + /// The value of the gauge. + /// + [JsonPropertyName("value")] + public float? Value { get; set; } = 0; + + /// + /// The format used to display the gauge's value. + /// + [JsonPropertyName("valueFormat")] + public GaugeChartValueFormat? ValueFormat { get; set; } = GaugeChartValueFormat.Percentage; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -11129,7 +14617,7 @@ public class VerticalBarChart : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this VerticalBarChart into a JSON string. + /// Serializes this GaugeChart into a JSON string. /// public string Serialize() { @@ -11143,127 +14631,169 @@ public string Serialize() ); } - public VerticalBarChart WithId(string value) + public GaugeChart WithKey(string value) + { + this.Key = value; + return this; + } + + public GaugeChart WithId(string value) { this.Id = value; return this; } - public VerticalBarChart WithRequires(HostCapabilities value) + public GaugeChart WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public VerticalBarChart WithLang(string value) + public GaugeChart WithLang(string value) { this.Lang = value; return this; } - public VerticalBarChart WithIsVisible(bool value) + public GaugeChart WithIsVisible(bool value) { this.IsVisible = value; return this; } - public VerticalBarChart WithSeparator(bool value) + public GaugeChart WithSeparator(bool value) { this.Separator = value; return this; } - public VerticalBarChart WithHeight(ElementHeight value) + public GaugeChart WithHeight(ElementHeight value) { this.Height = value; return this; } - public VerticalBarChart WithHorizontalAlignment(HorizontalAlignment value) + public GaugeChart WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public VerticalBarChart WithSpacing(Spacing value) + public GaugeChart WithSpacing(Spacing value) { this.Spacing = value; return this; } - public VerticalBarChart WithTargetWidth(TargetWidth value) + public GaugeChart WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public VerticalBarChart WithIsSortKey(bool value) + public GaugeChart WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public VerticalBarChart WithTitle(string value) + public GaugeChart WithTitle(string value) { this.Title = value; return this; } - public VerticalBarChart WithColorSet(ChartColorSet value) + public GaugeChart WithShowTitle(bool value) + { + this.ShowTitle = value; + return this; + } + + public GaugeChart WithColorSet(ChartColorSet value) { this.ColorSet = value; return this; } - public VerticalBarChart WithXAxisTitle(string value) + public GaugeChart WithMaxWidth(string value) { - this.XAxisTitle = value; + this.MaxWidth = value; + return this; + } + + public GaugeChart WithShowLegend(bool value) + { + this.ShowLegend = value; + return this; + } + + public GaugeChart WithMin(float value) + { + this.Min = value; + return this; + } + + public GaugeChart WithMax(float value) + { + this.Max = value; + return this; + } + + public GaugeChart WithSubLabel(string value) + { + this.SubLabel = value; + return this; + } + + public GaugeChart WithShowMinMax(bool value) + { + this.ShowMinMax = value; return this; } - public VerticalBarChart WithYAxisTitle(string value) + public GaugeChart WithShowNeedle(bool value) { - this.YAxisTitle = value; + this.ShowNeedle = value; return this; } - public VerticalBarChart WithData(params IList value) + public GaugeChart WithShowOutlines(bool value) { - this.Data = value; + this.ShowOutlines = value; return this; } - public VerticalBarChart WithColor(ChartColor value) + public GaugeChart WithSegments(params GaugeChartLegend[] value) { - this.Color = value; + this.Segments = new List(value); return this; } - public VerticalBarChart WithShowBarValues(bool value) + public GaugeChart WithSegments(IList value) { - this.ShowBarValues = value; + this.Segments = value; return this; } - public VerticalBarChart WithYMin(float value) + public GaugeChart WithValue(float value) { - this.YMin = value; + this.Value = value; return this; } - public VerticalBarChart WithYMax(float value) + public GaugeChart WithValueFormat(GaugeChartValueFormat value) { - this.YMax = value; + this.ValueFormat = value; return this; } - public VerticalBarChart WithGridArea(string value) + public GaugeChart WithGridArea(string value) { this.GridArea = value; return this; } - public VerticalBarChart WithFallback(IUnion value) + public GaugeChart WithFallback(IUnion value) { this.Fallback = value; return this; @@ -11271,38 +14801,44 @@ public VerticalBarChart WithFallback(IUnion value) } /// -/// Represents a data point in a vertical bar chart. +/// The legend of the chart. /// -public class VerticalBarChartDataValue : SerializableObject +public class GaugeChartLegend : SerializableObject { /// - /// Deserializes a JSON string into an object of type VerticalBarChartDataValue. + /// Deserializes a JSON string into an object of type GaugeChartLegend. /// - public static VerticalBarChartDataValue? Deserialize(string json) + public static GaugeChartLegend? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The x axis value of the data point. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("x")] - public IUnion? X { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The y axis value of the data point. + /// The size of the segment. /// - [JsonPropertyName("y")] - public float? Y { get; set; } + [JsonPropertyName("size")] + public float? Size { get; set; } = 0; /// - /// The color to use for the bar associated with the data point. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// The legend text associated with the segment. + /// + [JsonPropertyName("legend")] + public string? Legend { get; set; } + + /// + /// The color to use for the segment. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). /// [JsonPropertyName("color")] public ChartColor? Color { get; set; } /// - /// Serializes this VerticalBarChartDataValue into a JSON string. + /// Serializes this GaugeChartLegend into a JSON string. /// public string Serialize() { @@ -11316,19 +14852,25 @@ public string Serialize() ); } - public VerticalBarChartDataValue WithX(IUnion value) + public GaugeChartLegend WithKey(string value) { - this.X = value; + this.Key = value; return this; } - public VerticalBarChartDataValue WithY(float value) + public GaugeChartLegend WithSize(float value) { - this.Y = value; + this.Size = value; return this; } - public VerticalBarChartDataValue WithColor(ChartColor value) + public GaugeChartLegend WithLegend(string value) + { + this.Legend = value; + return this; + } + + public GaugeChartLegend WithColor(ChartColor value) { this.Color = value; return this; @@ -11336,23 +14878,29 @@ public VerticalBarChartDataValue WithColor(ChartColor value) } /// -/// A horizontal bar chart. +/// A formatted and syntax-colored code block. /// -public class HorizontalBarChart : CardElement +public class CodeBlock : CardElement { /// - /// Deserializes a JSON string into an object of type HorizontalBarChart. + /// Deserializes a JSON string into an object of type CodeBlock. /// - public static HorizontalBarChart? Deserialize(string json) + public static CodeBlock? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Chart.HorizontalBar**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **CodeBlock**. /// [JsonPropertyName("type")] - public string Type { get; } = "Chart.HorizontalBar"; + public string Type { get; } = "CodeBlock"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -11364,7 +14912,7 @@ public class HorizontalBarChart : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -11376,19 +14924,19 @@ public class HorizontalBarChart : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -11400,7 +14948,7 @@ public class HorizontalBarChart : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -11412,49 +14960,25 @@ public class HorizontalBarChart : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The title of the chart. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). - /// - [JsonPropertyName("colorSet")] - public ChartColorSet? ColorSet { get; set; } - - /// - /// The title of the x axis. - /// - [JsonPropertyName("xAxisTitle")] - public string? XAxisTitle { get; set; } - - /// - /// The title of the y axis. - /// - [JsonPropertyName("yAxisTitle")] - public string? YAxisTitle { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The color to use for all data points. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// The code snippet to display. /// - [JsonPropertyName("color")] - public ChartColor? Color { get; set; } + [JsonPropertyName("codeSnippet")] + public string? CodeSnippet { get; set; } /// - /// The data points in the chart. + /// The language the code snippet is expressed in. /// - [JsonPropertyName("data")] - public IList? Data { get; set; } + [JsonPropertyName("language")] + public CodeLanguage? Language { get; set; } = CodeLanguage.PlainText; /// - /// Controls how the chart should be visually laid out. + /// A number that represents the line in the file from where the code snippet was extracted. /// - [JsonPropertyName("displayMode")] - public HorizontalBarChartDisplayMode? DisplayMode { get; set; } + [JsonPropertyName("startLineNumber")] + public float? StartLineNumber { get; set; } = 1; /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -11469,7 +14993,7 @@ public class HorizontalBarChart : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this HorizontalBarChart into a JSON string. + /// Serializes this CodeBlock into a JSON string. /// public string Serialize() { @@ -11483,115 +15007,97 @@ public string Serialize() ); } - public HorizontalBarChart WithId(string value) + public CodeBlock WithKey(string value) + { + this.Key = value; + return this; + } + + public CodeBlock WithId(string value) { this.Id = value; return this; } - public HorizontalBarChart WithRequires(HostCapabilities value) + public CodeBlock WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public HorizontalBarChart WithLang(string value) + public CodeBlock WithLang(string value) { this.Lang = value; return this; } - public HorizontalBarChart WithIsVisible(bool value) + public CodeBlock WithIsVisible(bool value) { this.IsVisible = value; return this; } - public HorizontalBarChart WithSeparator(bool value) + public CodeBlock WithSeparator(bool value) { this.Separator = value; return this; } - public HorizontalBarChart WithHeight(ElementHeight value) + public CodeBlock WithHeight(ElementHeight value) { this.Height = value; return this; } - public HorizontalBarChart WithHorizontalAlignment(HorizontalAlignment value) + public CodeBlock WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public HorizontalBarChart WithSpacing(Spacing value) + public CodeBlock WithSpacing(Spacing value) { this.Spacing = value; return this; } - public HorizontalBarChart WithTargetWidth(TargetWidth value) + public CodeBlock WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public HorizontalBarChart WithIsSortKey(bool value) + public CodeBlock WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public HorizontalBarChart WithTitle(string value) - { - this.Title = value; - return this; - } - - public HorizontalBarChart WithColorSet(ChartColorSet value) - { - this.ColorSet = value; - return this; - } - - public HorizontalBarChart WithXAxisTitle(string value) - { - this.XAxisTitle = value; - return this; - } - - public HorizontalBarChart WithYAxisTitle(string value) - { - this.YAxisTitle = value; - return this; - } - - public HorizontalBarChart WithColor(ChartColor value) + public CodeBlock WithCodeSnippet(string value) { - this.Color = value; + this.CodeSnippet = value; return this; } - public HorizontalBarChart WithData(params IList value) + public CodeBlock WithLanguage(CodeLanguage value) { - this.Data = value; + this.Language = value; return this; } - public HorizontalBarChart WithDisplayMode(HorizontalBarChartDisplayMode value) + public CodeBlock WithStartLineNumber(float value) { - this.DisplayMode = value; + this.StartLineNumber = value; return this; } - public HorizontalBarChart WithGridArea(string value) + public CodeBlock WithGridArea(string value) { this.GridArea = value; return this; } - public HorizontalBarChart WithFallback(IUnion value) + public CodeBlock WithFallback(IUnion value) { this.Fallback = value; return this; @@ -11599,88 +15105,29 @@ public HorizontalBarChart WithFallback(IUnion valu } /// -/// Represents a single data point in a horizontal bar chart. -/// -public class HorizontalBarChartDataValue : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type HorizontalBarChartDataValue. - /// - public static HorizontalBarChartDataValue? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } - - /// - /// The x axis value of the data point. - /// - [JsonPropertyName("x")] - public string? X { get; set; } - - /// - /// The y axis value of the data point. - /// - [JsonPropertyName("y")] - public float? Y { get; set; } - - /// - /// The color of the bar associated with the data point. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). - /// - [JsonPropertyName("color")] - public ChartColor? Color { get; set; } - - /// - /// Serializes this HorizontalBarChartDataValue into a JSON string. - /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } - - public HorizontalBarChartDataValue WithX(string value) - { - this.X = value; - return this; - } - - public HorizontalBarChartDataValue WithY(float value) - { - this.Y = value; - return this; - } - - public HorizontalBarChartDataValue WithColor(ChartColor value) - { - this.Color = value; - return this; - } -} - -/// -/// A stacked horizontal bar chart. +/// Displays a user's information, including their profile picture. /// -public class StackedHorizontalBarChart : CardElement +public class ComUserMicrosoftGraphComponent : CardElement { /// - /// Deserializes a JSON string into an object of type StackedHorizontalBarChart. + /// Deserializes a JSON string into an object of type ComUserMicrosoftGraphComponent. /// - public static StackedHorizontalBarChart? Deserialize(string json) + public static ComUserMicrosoftGraphComponent? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Chart.HorizontalBar.Stacked**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Component**. /// [JsonPropertyName("type")] - public string Type { get; } = "Chart.HorizontalBar.Stacked"; + public string Type { get; } = "Component"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -11692,7 +15139,7 @@ public class StackedHorizontalBarChart : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -11704,19 +15151,19 @@ public class StackedHorizontalBarChart : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -11728,7 +15175,7 @@ public class StackedHorizontalBarChart : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -11740,43 +15187,19 @@ public class StackedHorizontalBarChart : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The title of the chart. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). - /// - [JsonPropertyName("colorSet")] - public ChartColorSet? ColorSet { get; set; } - - /// - /// The title of the x axis. - /// - [JsonPropertyName("xAxisTitle")] - public string? XAxisTitle { get; set; } - - /// - /// The title of the y axis. - /// - [JsonPropertyName("yAxisTitle")] - public string? YAxisTitle { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The color to use for all data points. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// Must be **graph.microsoft.com/user**. /// - [JsonPropertyName("color")] - public ChartColor? Color { get; set; } + [JsonPropertyName("name")] + public string Name { get; } = "graph.microsoft.com/user"; /// - /// The data to display in the chart. + /// The properties of the Persona component. /// - [JsonPropertyName("data")] - public IList? Data { get; set; } + [JsonPropertyName("properties")] + public PersonaProperties? Properties { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -11791,7 +15214,7 @@ public class StackedHorizontalBarChart : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this StackedHorizontalBarChart into a JSON string. + /// Serializes this ComUserMicrosoftGraphComponent into a JSON string. /// public string Serialize() { @@ -11805,109 +15228,85 @@ public string Serialize() ); } - public StackedHorizontalBarChart WithId(string value) + public ComUserMicrosoftGraphComponent WithKey(string value) + { + this.Key = value; + return this; + } + + public ComUserMicrosoftGraphComponent WithId(string value) { this.Id = value; return this; } - public StackedHorizontalBarChart WithRequires(HostCapabilities value) + public ComUserMicrosoftGraphComponent WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public StackedHorizontalBarChart WithLang(string value) + public ComUserMicrosoftGraphComponent WithLang(string value) { this.Lang = value; return this; } - public StackedHorizontalBarChart WithIsVisible(bool value) + public ComUserMicrosoftGraphComponent WithIsVisible(bool value) { this.IsVisible = value; return this; } - public StackedHorizontalBarChart WithSeparator(bool value) + public ComUserMicrosoftGraphComponent WithSeparator(bool value) { this.Separator = value; return this; } - public StackedHorizontalBarChart WithHeight(ElementHeight value) + public ComUserMicrosoftGraphComponent WithHeight(ElementHeight value) { this.Height = value; return this; } - public StackedHorizontalBarChart WithHorizontalAlignment(HorizontalAlignment value) + public ComUserMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public StackedHorizontalBarChart WithSpacing(Spacing value) + public ComUserMicrosoftGraphComponent WithSpacing(Spacing value) { this.Spacing = value; return this; } - public StackedHorizontalBarChart WithTargetWidth(TargetWidth value) + public ComUserMicrosoftGraphComponent WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public StackedHorizontalBarChart WithIsSortKey(bool value) + public ComUserMicrosoftGraphComponent WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public StackedHorizontalBarChart WithTitle(string value) - { - this.Title = value; - return this; - } - - public StackedHorizontalBarChart WithColorSet(ChartColorSet value) - { - this.ColorSet = value; - return this; - } - - public StackedHorizontalBarChart WithXAxisTitle(string value) - { - this.XAxisTitle = value; - return this; - } - - public StackedHorizontalBarChart WithYAxisTitle(string value) - { - this.YAxisTitle = value; - return this; - } - - public StackedHorizontalBarChart WithColor(ChartColor value) - { - this.Color = value; - return this; - } - - public StackedHorizontalBarChart WithData(params IList value) + public ComUserMicrosoftGraphComponent WithProperties(PersonaProperties value) { - this.Data = value; + this.Properties = value; return this; } - public StackedHorizontalBarChart WithGridArea(string value) + public ComUserMicrosoftGraphComponent WithGridArea(string value) { this.GridArea = value; return this; } - public StackedHorizontalBarChart WithFallback(IUnion value) + public ComUserMicrosoftGraphComponent WithFallback(IUnion value) { this.Fallback = value; return this; @@ -11915,91 +15314,56 @@ public StackedHorizontalBarChart WithFallback(IUnion -/// Defines the collection of data series to display in as a stacked horizontal bar chart. +/// Represents the properties of a Persona component. /// -public class StackedHorizontalBarChartData : SerializableObject +public class PersonaProperties : SerializableObject { /// - /// Deserializes a JSON string into an object of type StackedHorizontalBarChartData. + /// Deserializes a JSON string into an object of type PersonaProperties. /// - public static StackedHorizontalBarChartData? Deserialize(string json) + public static PersonaProperties? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The title of the series. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// The data points in the series. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("data")] - public IList? Data { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Serializes this StackedHorizontalBarChartData into a JSON string. + /// The Id of the persona. /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } - - public StackedHorizontalBarChartData WithTitle(string value) - { - this.Title = value; - return this; - } - - public StackedHorizontalBarChartData WithData(params IList value) - { - this.Data = value; - return this; - } -} + [JsonPropertyName("id")] + public string? Id { get; set; } -/// -/// A data point in a series. -/// -public class StackedHorizontalBarChartDataPoint : SerializableObject -{ /// - /// Deserializes a JSON string into an object of type StackedHorizontalBarChartDataPoint. + /// The UPN of the persona. /// - public static StackedHorizontalBarChartDataPoint? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } + [JsonPropertyName("userPrincipalName")] + public string? UserPrincipalName { get; set; } /// - /// The legend associated with the data point. + /// The display name of the persona. /// - [JsonPropertyName("legend")] - public string? Legend { get; set; } + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } /// - /// The value of the data point. + /// Defines the style of the icon for the persona. /// - [JsonPropertyName("value")] - public float? Value { get; set; } + [JsonPropertyName("iconStyle")] + public PersonaIconStyle? IconStyle { get; set; } /// - /// The color to use to render the bar associated with the data point. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// Defines how the persona should be displayed. /// - [JsonPropertyName("color")] - public ChartColor? Color { get; set; } + [JsonPropertyName("style")] + public PersonaDisplayStyle? Style { get; set; } /// - /// Serializes this StackedHorizontalBarChartDataPoint into a JSON string. + /// Serializes this PersonaProperties into a JSON string. /// public string Serialize() { @@ -12013,43 +15377,67 @@ public string Serialize() ); } - public StackedHorizontalBarChartDataPoint WithLegend(string value) + public PersonaProperties WithKey(string value) { - this.Legend = value; + this.Key = value; return this; } - public StackedHorizontalBarChartDataPoint WithValue(float value) + public PersonaProperties WithId(string value) { - this.Value = value; + this.Id = value; return this; } - public StackedHorizontalBarChartDataPoint WithColor(ChartColor value) + public PersonaProperties WithUserPrincipalName(string value) { - this.Color = value; + this.UserPrincipalName = value; + return this; + } + + public PersonaProperties WithDisplayName(string value) + { + this.DisplayName = value; + return this; + } + + public PersonaProperties WithIconStyle(PersonaIconStyle value) + { + this.IconStyle = value; + return this; + } + + public PersonaProperties WithStyle(PersonaDisplayStyle value) + { + this.Style = value; return this; } } /// -/// A line chart. +/// Displays multiple users' information, including their profile pictures. /// -public class LineChart : CardElement +public class ComUsersMicrosoftGraphComponent : CardElement { /// - /// Deserializes a JSON string into an object of type LineChart. + /// Deserializes a JSON string into an object of type ComUsersMicrosoftGraphComponent. /// - public static LineChart? Deserialize(string json) + public static ComUsersMicrosoftGraphComponent? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Chart.Line**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Component**. /// [JsonPropertyName("type")] - public string Type { get; } = "Chart.Line"; + public string Type { get; } = "Component"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -12061,7 +15449,7 @@ public class LineChart : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -12073,91 +15461,55 @@ public class LineChart : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } - - /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. - /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls how the element should be horizontally aligned. - /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } - - /// - /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. - /// - [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } - - /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). - /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } - - /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. - /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The title of the chart. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } + public bool? Separator { get; set; } = false; /// - /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// - [JsonPropertyName("colorSet")] - public ChartColorSet? ColorSet { get; set; } + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// - /// The title of the x axis. + /// Controls how the element should be horizontally aligned. /// - [JsonPropertyName("xAxisTitle")] - public string? XAxisTitle { get; set; } + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// - /// The title of the y axis. + /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// - [JsonPropertyName("yAxisTitle")] - public string? YAxisTitle { get; set; } + [JsonPropertyName("spacing")] + public Spacing? Spacing { get; set; } = Spacing.Default; /// - /// The color to use for all data points. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). /// - [JsonPropertyName("color")] - public ChartColor? Color { get; set; } + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } /// - /// The data point series in the line chart. + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// - [JsonPropertyName("data")] - public IList? Data { get; set; } + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; /// - /// The maximum y range. + /// Must be **graph.microsoft.com/users**. /// - [JsonPropertyName("yMin")] - public float? YMin { get; set; } + [JsonPropertyName("name")] + public string Name { get; } = "graph.microsoft.com/users"; /// - /// The minimum y range. + /// The properties of the PersonaSet component. /// - [JsonPropertyName("yMax")] - public float? YMax { get; set; } + [JsonPropertyName("properties")] + public PersonaSetProperties? Properties { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -12172,7 +15524,7 @@ public class LineChart : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this LineChart into a JSON string. + /// Serializes this ComUsersMicrosoftGraphComponent into a JSON string. /// public string Serialize() { @@ -12186,121 +15538,85 @@ public string Serialize() ); } - public LineChart WithId(string value) + public ComUsersMicrosoftGraphComponent WithKey(string value) + { + this.Key = value; + return this; + } + + public ComUsersMicrosoftGraphComponent WithId(string value) { this.Id = value; return this; } - public LineChart WithRequires(HostCapabilities value) + public ComUsersMicrosoftGraphComponent WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public LineChart WithLang(string value) + public ComUsersMicrosoftGraphComponent WithLang(string value) { this.Lang = value; return this; } - public LineChart WithIsVisible(bool value) + public ComUsersMicrosoftGraphComponent WithIsVisible(bool value) { this.IsVisible = value; return this; } - public LineChart WithSeparator(bool value) + public ComUsersMicrosoftGraphComponent WithSeparator(bool value) { this.Separator = value; return this; } - public LineChart WithHeight(ElementHeight value) + public ComUsersMicrosoftGraphComponent WithHeight(ElementHeight value) { this.Height = value; return this; } - public LineChart WithHorizontalAlignment(HorizontalAlignment value) + public ComUsersMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public LineChart WithSpacing(Spacing value) + public ComUsersMicrosoftGraphComponent WithSpacing(Spacing value) { this.Spacing = value; return this; } - public LineChart WithTargetWidth(TargetWidth value) + public ComUsersMicrosoftGraphComponent WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public LineChart WithIsSortKey(bool value) + public ComUsersMicrosoftGraphComponent WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public LineChart WithTitle(string value) - { - this.Title = value; - return this; - } - - public LineChart WithColorSet(ChartColorSet value) - { - this.ColorSet = value; - return this; - } - - public LineChart WithXAxisTitle(string value) - { - this.XAxisTitle = value; - return this; - } - - public LineChart WithYAxisTitle(string value) - { - this.YAxisTitle = value; - return this; - } - - public LineChart WithColor(ChartColor value) - { - this.Color = value; - return this; - } - - public LineChart WithData(params IList value) - { - this.Data = value; - return this; - } - - public LineChart WithYMin(float value) - { - this.YMin = value; - return this; - } - - public LineChart WithYMax(float value) + public ComUsersMicrosoftGraphComponent WithProperties(PersonaSetProperties value) { - this.YMax = value; + this.Properties = value; return this; } - public LineChart WithGridArea(string value) + public ComUsersMicrosoftGraphComponent WithGridArea(string value) { this.GridArea = value; return this; } - public LineChart WithFallback(IUnion value) + public ComUsersMicrosoftGraphComponent WithFallback(IUnion value) { this.Fallback = value; return this; @@ -12308,38 +15624,44 @@ public LineChart WithFallback(IUnion value) } /// -/// Represents a collection of data points series in a line chart. +/// Represents the properties of a PersonaSet component. /// -public class LineChartData : SerializableObject +public class PersonaSetProperties : SerializableObject { /// - /// Deserializes a JSON string into an object of type LineChartData. + /// Deserializes a JSON string into an object of type PersonaSetProperties. /// - public static LineChartData? Deserialize(string json) + public static PersonaSetProperties? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The legend of the chart. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("legend")] - public string? Legend { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The data points in the series. + /// The users a PersonaSet component should display. /// - [JsonPropertyName("values")] - public IList? Values { get; set; } + [JsonPropertyName("users")] + public IList? Users { get; set; } /// - /// The color all data points in the series. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// Defines the style of the icon for the personas in the set. /// - [JsonPropertyName("color")] - public ChartColor? Color { get; set; } + [JsonPropertyName("iconStyle")] + public PersonaIconStyle? IconStyle { get; set; } /// - /// Serializes this LineChartData into a JSON string. + /// Defines how each persona in the set should be displayed. + /// + [JsonPropertyName("style")] + public PersonaDisplayStyle? Style { get; set; } + + /// + /// Serializes this PersonaSetProperties into a JSON string. /// public string Serialize() { @@ -12353,100 +15675,61 @@ public string Serialize() ); } - public LineChartData WithLegend(string value) + public PersonaSetProperties WithKey(string value) { - this.Legend = value; + this.Key = value; return this; } - public LineChartData WithValues(params IList value) + public PersonaSetProperties WithUsers(params PersonaProperties[] value) { - this.Values = value; + this.Users = new List(value); return this; } - public LineChartData WithColor(ChartColor value) + public PersonaSetProperties WithUsers(IList value) { - this.Color = value; + this.Users = value; return this; } -} - -/// -/// Represents a single data point in a line chart. -/// -public class LineChartValue : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type LineChartValue. - /// - public static LineChartValue? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } - - /// - /// The x axis value of the data point. - /// - /// If all x values of the x [Chart.Line](https://adaptivecards.microsoft.com/?topic=Chart.Line) are expressed as a number, or if all x values are expressed as a date string in the `YYYY-MM-DD` format, the chart will be rendered as a time series chart, i.e. x axis values will span across the minimum x value to maximum x value range. - /// - /// Otherwise, if x values are represented as a mix of numbers and strings or if at least one x value isn't in the `YYYY-MM-DD` format, the chart will be rendered as a categorical chart, i.e. x axis values will be displayed as categories. - /// - [JsonPropertyName("x")] - public IUnion? X { get; set; } - - /// - /// The y axis value of the data point. - /// - [JsonPropertyName("y")] - public float? Y { get; set; } - - /// - /// Serializes this LineChartValue into a JSON string. - /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } - public LineChartValue WithX(IUnion value) + public PersonaSetProperties WithIconStyle(PersonaIconStyle value) { - this.X = value; + this.IconStyle = value; return this; } - public LineChartValue WithY(float value) + public PersonaSetProperties WithStyle(PersonaDisplayStyle value) { - this.Y = value; + this.Style = value; return this; } } /// -/// A gauge chart. +/// Displays information about a generic graph resource. /// -public class GaugeChart : CardElement +public class ComResourceMicrosoftGraphComponent : CardElement { /// - /// Deserializes a JSON string into an object of type GaugeChart. + /// Deserializes a JSON string into an object of type ComResourceMicrosoftGraphComponent. /// - public static GaugeChart? Deserialize(string json) + public static ComResourceMicrosoftGraphComponent? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Chart.Gauge**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Component**. /// [JsonPropertyName("type")] - public string Type { get; } = "Chart.Gauge"; + public string Type { get; } = "Component"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -12458,7 +15741,7 @@ public class GaugeChart : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -12470,19 +15753,19 @@ public class GaugeChart : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -12494,7 +15777,7 @@ public class GaugeChart : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -12505,62 +15788,20 @@ public class GaugeChart : CardElement /// /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The title of the chart. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// The name of the set of colors to use to render the chart. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). - /// - [JsonPropertyName("colorSet")] - public ChartColorSet? ColorSet { get; set; } - - /// - /// The minimum value of the gauge. - /// - [JsonPropertyName("min")] - public float? Min { get; set; } - - /// - /// The maximum value of the gauge. - /// - [JsonPropertyName("max")] - public float? Max { get; set; } - - /// - /// The sub-label of the gauge. - /// - [JsonPropertyName("subLabel")] - public string? SubLabel { get; set; } - - /// - /// Controls whether the min/max values should be displayed. - /// - [JsonPropertyName("showMinMax")] - public bool? ShowMinMax { get; set; } - - /// - /// The segments to display in the gauge. - /// - [JsonPropertyName("segments")] - public IList? Segments { get; set; } + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; /// - /// The value of the gauge. + /// Must be **graph.microsoft.com/resource**. /// - [JsonPropertyName("value")] - public float? Value { get; set; } + [JsonPropertyName("name")] + public string Name { get; } = "graph.microsoft.com/resource"; /// - /// The format used to display the gauge's value. + /// The properties of the resource. /// - [JsonPropertyName("valueFormat")] - public GaugeChartValueFormat? ValueFormat { get; set; } + [JsonPropertyName("properties")] + public ResourceProperties? Properties { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -12575,7 +15816,7 @@ public class GaugeChart : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this GaugeChart into a JSON string. + /// Serializes this ComResourceMicrosoftGraphComponent into a JSON string. /// public string Serialize() { @@ -12589,166 +15830,195 @@ public string Serialize() ); } - public GaugeChart WithId(string value) + public ComResourceMicrosoftGraphComponent WithKey(string value) + { + this.Key = value; + return this; + } + + public ComResourceMicrosoftGraphComponent WithId(string value) { this.Id = value; return this; } - public GaugeChart WithRequires(HostCapabilities value) + public ComResourceMicrosoftGraphComponent WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public GaugeChart WithLang(string value) + public ComResourceMicrosoftGraphComponent WithLang(string value) { this.Lang = value; return this; } - public GaugeChart WithIsVisible(bool value) + public ComResourceMicrosoftGraphComponent WithIsVisible(bool value) { this.IsVisible = value; return this; } - public GaugeChart WithSeparator(bool value) + public ComResourceMicrosoftGraphComponent WithSeparator(bool value) { this.Separator = value; return this; } - public GaugeChart WithHeight(ElementHeight value) + public ComResourceMicrosoftGraphComponent WithHeight(ElementHeight value) { this.Height = value; return this; } - public GaugeChart WithHorizontalAlignment(HorizontalAlignment value) + public ComResourceMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public GaugeChart WithSpacing(Spacing value) + public ComResourceMicrosoftGraphComponent WithSpacing(Spacing value) { this.Spacing = value; return this; } - public GaugeChart WithTargetWidth(TargetWidth value) + public ComResourceMicrosoftGraphComponent WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public GaugeChart WithIsSortKey(bool value) + public ComResourceMicrosoftGraphComponent WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public GaugeChart WithTitle(string value) + public ComResourceMicrosoftGraphComponent WithProperties(ResourceProperties value) { - this.Title = value; + this.Properties = value; return this; } - public GaugeChart WithColorSet(ChartColorSet value) + public ComResourceMicrosoftGraphComponent WithGridArea(string value) { - this.ColorSet = value; + this.GridArea = value; return this; } - public GaugeChart WithMin(float value) + public ComResourceMicrosoftGraphComponent WithFallback(IUnion value) { - this.Min = value; + this.Fallback = value; return this; } +} - public GaugeChart WithMax(float value) +/// +/// Represents the properties of a resource component. +/// +public class ResourceProperties : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type ResourceProperties. + /// + public static ResourceProperties? Deserialize(string json) { - this.Max = value; - return this; + return JsonSerializer.Deserialize(json); } - public GaugeChart WithSubLabel(string value) - { - this.SubLabel = value; - return this; - } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } - public GaugeChart WithShowMinMax(bool value) - { - this.ShowMinMax = value; - return this; - } + /// + /// The Id of the resource. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The reference to the resource. + /// + [JsonPropertyName("resourceReference")] + public IDictionary? ResourceReference { get; set; } + + /// + /// The visualization of the resource. + /// + [JsonPropertyName("resourceVisualization")] + public ResourceVisualization? ResourceVisualization { get; set; } - public GaugeChart WithSegments(params IList value) + /// + /// Serializes this ResourceProperties into a JSON string. + /// + public string Serialize() { - this.Segments = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public GaugeChart WithValue(float value) + public ResourceProperties WithKey(string value) { - this.Value = value; + this.Key = value; return this; } - public GaugeChart WithValueFormat(GaugeChartValueFormat value) + public ResourceProperties WithId(string value) { - this.ValueFormat = value; + this.Id = value; return this; } - public GaugeChart WithGridArea(string value) + public ResourceProperties WithResourceReference(IDictionary value) { - this.GridArea = value; + this.ResourceReference = value; return this; } - public GaugeChart WithFallback(IUnion value) + public ResourceProperties WithResourceVisualization(ResourceVisualization value) { - this.Fallback = value; + this.ResourceVisualization = value; return this; } } /// -/// The legend of the chart. +/// Represents a visualization of a resource. /// -public class GaugeChartLegend : SerializableObject +public class ResourceVisualization : SerializableObject { /// - /// Deserializes a JSON string into an object of type GaugeChartLegend. + /// Deserializes a JSON string into an object of type ResourceVisualization. /// - public static GaugeChartLegend? Deserialize(string json) + public static ResourceVisualization? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The size of the segment. - /// - [JsonPropertyName("size")] - public float? Size { get; set; } - - /// - /// The legend text associated with the segment. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("legend")] - public string? Legend { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The color to use for the segment. See [Chart colors reference](https://adaptivecards.microsoft.com/?topic=chart-colors-reference). + /// The media associated with the resource. /// - [JsonPropertyName("color")] - public ChartColor? Color { get; set; } + [JsonPropertyName("media")] + public string? Media { get; set; } /// - /// Serializes this GaugeChartLegend into a JSON string. + /// Serializes this ResourceVisualization into a JSON string. /// public string Serialize() { @@ -12762,43 +16032,43 @@ public string Serialize() ); } - public GaugeChartLegend WithSize(float value) - { - this.Size = value; - return this; - } - - public GaugeChartLegend WithLegend(string value) + public ResourceVisualization WithKey(string value) { - this.Legend = value; + this.Key = value; return this; } - public GaugeChartLegend WithColor(ChartColor value) + public ResourceVisualization WithMedia(string value) { - this.Color = value; + this.Media = value; return this; } } /// -/// A formatted and syntax-colored code block. +/// Displays information about a file resource. /// -public class CodeBlock : CardElement +public class ComFileMicrosoftGraphComponent : CardElement { /// - /// Deserializes a JSON string into an object of type CodeBlock. + /// Deserializes a JSON string into an object of type ComFileMicrosoftGraphComponent. /// - public static CodeBlock? Deserialize(string json) + public static ComFileMicrosoftGraphComponent? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **CodeBlock**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **Component**. /// [JsonPropertyName("type")] - public string Type { get; } = "CodeBlock"; + public string Type { get; } = "Component"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -12810,7 +16080,7 @@ public class CodeBlock : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -12822,19 +16092,19 @@ public class CodeBlock : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -12846,7 +16116,7 @@ public class CodeBlock : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -12858,25 +16128,19 @@ public class CodeBlock : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The code snippet to display. - /// - [JsonPropertyName("codeSnippet")] - public string? CodeSnippet { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// The language the code snippet is expressed in. + /// Must be **graph.microsoft.com/file**. /// - [JsonPropertyName("language")] - public CodeLanguage? Language { get; set; } + [JsonPropertyName("name")] + public string Name { get; } = "graph.microsoft.com/file"; /// - /// A number that represents the line in the file from where the code snippet was extracted. + /// The properties of the file. /// - [JsonPropertyName("startLineNumber")] - public float? StartLineNumber { get; set; } + [JsonPropertyName("properties")] + public FileProperties? Properties { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -12891,7 +16155,7 @@ public class CodeBlock : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this CodeBlock into a JSON string. + /// Serializes this ComFileMicrosoftGraphComponent into a JSON string. /// public string Serialize() { @@ -12905,110 +16169,187 @@ public string Serialize() ); } - public CodeBlock WithId(string value) + public ComFileMicrosoftGraphComponent WithKey(string value) + { + this.Key = value; + return this; + } + + public ComFileMicrosoftGraphComponent WithId(string value) { this.Id = value; return this; } - public CodeBlock WithRequires(HostCapabilities value) + public ComFileMicrosoftGraphComponent WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public CodeBlock WithLang(string value) + public ComFileMicrosoftGraphComponent WithLang(string value) { this.Lang = value; return this; } - public CodeBlock WithIsVisible(bool value) + public ComFileMicrosoftGraphComponent WithIsVisible(bool value) { this.IsVisible = value; return this; } - public CodeBlock WithSeparator(bool value) + public ComFileMicrosoftGraphComponent WithSeparator(bool value) { this.Separator = value; return this; } - public CodeBlock WithHeight(ElementHeight value) + public ComFileMicrosoftGraphComponent WithHeight(ElementHeight value) { this.Height = value; return this; } - public CodeBlock WithHorizontalAlignment(HorizontalAlignment value) + public ComFileMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public CodeBlock WithSpacing(Spacing value) + public ComFileMicrosoftGraphComponent WithSpacing(Spacing value) { this.Spacing = value; return this; } - public CodeBlock WithTargetWidth(TargetWidth value) + public ComFileMicrosoftGraphComponent WithTargetWidth(TargetWidth value) + { + this.TargetWidth = value; + return this; + } + + public ComFileMicrosoftGraphComponent WithIsSortKey(bool value) + { + this.IsSortKey = value; + return this; + } + + public ComFileMicrosoftGraphComponent WithProperties(FileProperties value) + { + this.Properties = value; + return this; + } + + public ComFileMicrosoftGraphComponent WithGridArea(string value) + { + this.GridArea = value; + return this; + } + + public ComFileMicrosoftGraphComponent WithFallback(IUnion value) + { + this.Fallback = value; + return this; + } +} + +/// +/// Represents the properties of a file component. +/// +public class FileProperties : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type FileProperties. + /// + public static FileProperties? Deserialize(string json) { - this.TargetWidth = value; - return this; + return JsonSerializer.Deserialize(json); } - public CodeBlock WithIsSortKey(bool value) - { - this.IsSortKey = value; - return this; - } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } - public CodeBlock WithCodeSnippet(string value) + /// + /// The name of the file. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The file extension. + /// + [JsonPropertyName("extension")] + public string? Extension { get; set; } + + /// + /// The URL of the file. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Serializes this FileProperties into a JSON string. + /// + public string Serialize() { - this.CodeSnippet = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public CodeBlock WithLanguage(CodeLanguage value) + public FileProperties WithKey(string value) { - this.Language = value; + this.Key = value; return this; } - public CodeBlock WithStartLineNumber(float value) + public FileProperties WithName(string value) { - this.StartLineNumber = value; + this.Name = value; return this; } - public CodeBlock WithGridArea(string value) + public FileProperties WithExtension(string value) { - this.GridArea = value; + this.Extension = value; return this; } - public CodeBlock WithFallback(IUnion value) + public FileProperties WithUrl(string value) { - this.Fallback = value; + this.Url = value; return this; } } /// -/// Displays a user's information, including their profile picture. +/// Displays information about a calendar event. /// -public class ComUserMicrosoftGraphComponent : CardElement +public class ComEventMicrosoftGraphComponent : CardElement { /// - /// Deserializes a JSON string into an object of type ComUserMicrosoftGraphComponent. + /// Deserializes a JSON string into an object of type ComEventMicrosoftGraphComponent. /// - public static ComUserMicrosoftGraphComponent? Deserialize(string json) + public static ComEventMicrosoftGraphComponent? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + /// /// Must be **Component**. /// @@ -13025,7 +16366,7 @@ public class ComUserMicrosoftGraphComponent : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -13037,19 +16378,19 @@ public class ComUserMicrosoftGraphComponent : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -13061,7 +16402,7 @@ public class ComUserMicrosoftGraphComponent : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -13073,19 +16414,19 @@ public class ComUserMicrosoftGraphComponent : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// Must be **graph.microsoft.com/user**. + /// Must be **graph.microsoft.com/event**. /// [JsonPropertyName("name")] - public string Name { get; } = "graph.microsoft.com/user"; + public string Name { get; } = "graph.microsoft.com/event"; /// - /// The properties of the user. + /// The properties of the event. /// [JsonPropertyName("properties")] - public PersonaProperties? Properties { get; set; } + public CalendarEventProperties? Properties { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -13100,7 +16441,7 @@ public class ComUserMicrosoftGraphComponent : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this ComUserMicrosoftGraphComponent into a JSON string. + /// Serializes this ComEventMicrosoftGraphComponent into a JSON string. /// public string Serialize() { @@ -13114,79 +16455,85 @@ public string Serialize() ); } - public ComUserMicrosoftGraphComponent WithId(string value) + public ComEventMicrosoftGraphComponent WithKey(string value) + { + this.Key = value; + return this; + } + + public ComEventMicrosoftGraphComponent WithId(string value) { this.Id = value; return this; } - public ComUserMicrosoftGraphComponent WithRequires(HostCapabilities value) + public ComEventMicrosoftGraphComponent WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ComUserMicrosoftGraphComponent WithLang(string value) + public ComEventMicrosoftGraphComponent WithLang(string value) { this.Lang = value; return this; } - public ComUserMicrosoftGraphComponent WithIsVisible(bool value) + public ComEventMicrosoftGraphComponent WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ComUserMicrosoftGraphComponent WithSeparator(bool value) + public ComEventMicrosoftGraphComponent WithSeparator(bool value) { this.Separator = value; return this; } - public ComUserMicrosoftGraphComponent WithHeight(ElementHeight value) + public ComEventMicrosoftGraphComponent WithHeight(ElementHeight value) { this.Height = value; return this; } - public ComUserMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) + public ComEventMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public ComUserMicrosoftGraphComponent WithSpacing(Spacing value) + public ComEventMicrosoftGraphComponent WithSpacing(Spacing value) { this.Spacing = value; return this; } - public ComUserMicrosoftGraphComponent WithTargetWidth(TargetWidth value) + public ComEventMicrosoftGraphComponent WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public ComUserMicrosoftGraphComponent WithIsSortKey(bool value) + public ComEventMicrosoftGraphComponent WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public ComUserMicrosoftGraphComponent WithProperties(PersonaProperties value) + public ComEventMicrosoftGraphComponent WithProperties(CalendarEventProperties value) { this.Properties = value; return this; } - public ComUserMicrosoftGraphComponent WithGridArea(string value) + public ComEventMicrosoftGraphComponent WithGridArea(string value) { this.GridArea = value; return this; } - public ComUserMicrosoftGraphComponent WithFallback(IUnion value) + public ComEventMicrosoftGraphComponent WithFallback(IUnion value) { this.Fallback = value; return this; @@ -13194,163 +16541,98 @@ public ComUserMicrosoftGraphComponent WithFallback(IUnion -/// Represents the properties of a Persona component. -/// -public class PersonaProperties : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type PersonaProperties. - /// - public static PersonaProperties? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } - - /// - /// The UPN of the persona. - /// - [JsonPropertyName("userPrincipalName")] - public string? UserPrincipalName { get; set; } - - /// - /// The display name of the persona. - /// - [JsonPropertyName("displayName")] - public string? DisplayName { get; set; } - - /// - /// Serializes this PersonaProperties into a JSON string. - /// - public string Serialize() - { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); - } - - public PersonaProperties WithUserPrincipalName(string value) - { - this.UserPrincipalName = value; - return this; - } - - public PersonaProperties WithDisplayName(string value) - { - this.DisplayName = value; - return this; - } -} - -/// -/// Displays multiple users' information, including their profile pictures. +/// The properties of a calendar event. /// -public class ComUsersMicrosoftGraphComponent : CardElement +public class CalendarEventProperties : SerializableObject { /// - /// Deserializes a JSON string into an object of type ComUsersMicrosoftGraphComponent. + /// Deserializes a JSON string into an object of type CalendarEventProperties. /// - public static ComUsersMicrosoftGraphComponent? Deserialize(string json) + public static CalendarEventProperties? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Component**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("type")] - public string Type { get; } = "Component"; + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// The ID of the event. /// [JsonPropertyName("id")] public string? Id { get; set; } /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). - /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } - - /// - /// The locale associated with the element. - /// - [JsonPropertyName("lang")] - public string? Lang { get; set; } - - /// - /// Controls the visibility of the element. + /// The title of the event. /// - [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + [JsonPropertyName("title")] + public string? Title { get; set; } /// - /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. + /// The start date and time of the event. /// - [JsonPropertyName("separator")] - public bool? Separator { get; set; } + [JsonPropertyName("start")] + public string? Start { get; set; } /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// The end date and time of the event. /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + [JsonPropertyName("end")] + public string? End { get; set; } /// - /// Controls how the element should be horizontally aligned. + /// The status of the event. /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + [JsonPropertyName("status")] + public string? Status { get; set; } /// - /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. + /// The locations of the event. /// - [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + [JsonPropertyName("locations")] + public IList? Locations { get; set; } /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). + /// The URL of the online meeting. /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } + [JsonPropertyName("onlineMeetingUrl")] + public string? OnlineMeetingUrl { get; set; } /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// Indicates if the event is all day. /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + [JsonPropertyName("isAllDay")] + public bool? IsAllDay { get; set; } /// - /// Must be **graph.microsoft.com/users**. + /// The extension of the event. /// - [JsonPropertyName("name")] - public string Name { get; } = "graph.microsoft.com/users"; + [JsonPropertyName("extension")] + public string? Extension { get; set; } /// - /// The properties of the set. + /// The URL of the event. /// - [JsonPropertyName("properties")] - public PersonaSetProperties? Properties { get; set; } + [JsonPropertyName("url")] + public string? Url { get; set; } /// - /// The area of a Layout.AreaGrid layout in which an element should be displayed. + /// The attendees of the event. /// - [JsonPropertyName("grid.area")] - public string? GridArea { get; set; } + [JsonPropertyName("attendees")] + public IList? Attendees { get; set; } /// - /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The organizer of the event. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("organizer")] + public CalendarEventAttendee? Organizer { get; set; } /// - /// Serializes this ComUsersMicrosoftGraphComponent into a JSON string. + /// Serializes this CalendarEventProperties into a JSON string. /// public string Serialize() { @@ -13364,106 +16646,148 @@ public string Serialize() ); } - public ComUsersMicrosoftGraphComponent WithId(string value) + public CalendarEventProperties WithKey(string value) + { + this.Key = value; + return this; + } + + public CalendarEventProperties WithId(string value) { this.Id = value; return this; } - public ComUsersMicrosoftGraphComponent WithRequires(HostCapabilities value) + public CalendarEventProperties WithTitle(string value) { - this.Requires = value; + this.Title = value; return this; } - public ComUsersMicrosoftGraphComponent WithLang(string value) + public CalendarEventProperties WithStart(string value) { - this.Lang = value; + this.Start = value; return this; } - public ComUsersMicrosoftGraphComponent WithIsVisible(bool value) + public CalendarEventProperties WithEnd(string value) { - this.IsVisible = value; + this.End = value; return this; } - public ComUsersMicrosoftGraphComponent WithSeparator(bool value) + public CalendarEventProperties WithStatus(string value) { - this.Separator = value; + this.Status = value; return this; } - public ComUsersMicrosoftGraphComponent WithHeight(ElementHeight value) + public CalendarEventProperties WithLocations(params string[] value) { - this.Height = value; + this.Locations = new List(value); return this; } - public ComUsersMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) + public CalendarEventProperties WithLocations(IList value) { - this.HorizontalAlignment = value; + this.Locations = value; return this; } - public ComUsersMicrosoftGraphComponent WithSpacing(Spacing value) + public CalendarEventProperties WithOnlineMeetingUrl(string value) { - this.Spacing = value; + this.OnlineMeetingUrl = value; return this; } - public ComUsersMicrosoftGraphComponent WithTargetWidth(TargetWidth value) + public CalendarEventProperties WithIsAllDay(bool value) { - this.TargetWidth = value; + this.IsAllDay = value; return this; } - public ComUsersMicrosoftGraphComponent WithIsSortKey(bool value) + public CalendarEventProperties WithExtension(string value) { - this.IsSortKey = value; + this.Extension = value; return this; } - public ComUsersMicrosoftGraphComponent WithProperties(PersonaSetProperties value) + public CalendarEventProperties WithUrl(string value) { - this.Properties = value; + this.Url = value; return this; } - public ComUsersMicrosoftGraphComponent WithGridArea(string value) + public CalendarEventProperties WithAttendees(params CalendarEventAttendee[] value) { - this.GridArea = value; + this.Attendees = new List(value); return this; } - public ComUsersMicrosoftGraphComponent WithFallback(IUnion value) + public CalendarEventProperties WithAttendees(IList value) { - this.Fallback = value; + this.Attendees = value; + return this; + } + + public CalendarEventProperties WithOrganizer(CalendarEventAttendee value) + { + this.Organizer = value; return this; } } /// -/// Represents the properties of a PersonaSet component. +/// Represents a calendar event attendee. /// -public class PersonaSetProperties : SerializableObject +public class CalendarEventAttendee : SerializableObject { /// - /// Deserializes a JSON string into an object of type PersonaSetProperties. + /// Deserializes a JSON string into an object of type CalendarEventAttendee. /// - public static PersonaSetProperties? Deserialize(string json) + public static CalendarEventAttendee? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The users a PersonaSet component should display. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("users")] - public IList? Users { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Serializes this PersonaSetProperties into a JSON string. + /// The name of the attendee. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The email address of the attendee. + /// + [JsonPropertyName("email")] + public string? Email { get; set; } + + /// + /// The title of the attendee. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The type of the attendee. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The status of the attendee. + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// Serializes this CalendarEventAttendee into a JSON string. /// public string Serialize() { @@ -13477,31 +16801,67 @@ public string Serialize() ); } - public PersonaSetProperties WithUsers(params IList value) + public CalendarEventAttendee WithKey(string value) { - this.Users = value; + this.Key = value; + return this; + } + + public CalendarEventAttendee WithName(string value) + { + this.Name = value; + return this; + } + + public CalendarEventAttendee WithEmail(string value) + { + this.Email = value; + return this; + } + + public CalendarEventAttendee WithTitle(string value) + { + this.Title = value; + return this; + } + + public CalendarEventAttendee WithType(string value) + { + this.Type = value; + return this; + } + + public CalendarEventAttendee WithStatus(string value) + { + this.Status = value; return this; } } /// -/// Displays information about a generic graph resource. +/// A page inside a Carousel element. /// -public class ComResourceMicrosoftGraphComponent : CardElement +public class CarouselPage : CardElement { /// - /// Deserializes a JSON string into an object of type ComResourceMicrosoftGraphComponent. + /// Deserializes a JSON string into an object of type CarouselPage. /// - public static ComResourceMicrosoftGraphComponent? Deserialize(string json) + public static CarouselPage? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Component**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **CarouselPage**. /// [JsonPropertyName("type")] - public string Type { get; } = "Component"; + public string Type { get; } = "CarouselPage"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -13513,7 +16873,7 @@ public class ComResourceMicrosoftGraphComponent : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -13525,55 +16885,85 @@ public class ComResourceMicrosoftGraphComponent : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// - /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. + /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// - [JsonPropertyName("separator")] - public bool? Separator { get; set; } + [JsonPropertyName("height")] + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + [JsonPropertyName("targetWidth")] + public TargetWidth? TargetWidth { get; set; } /// - /// Controls how the element should be horizontally aligned. + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; /// - /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. + /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. /// - [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). + /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } + [JsonPropertyName("style")] + public ContainerStyle? Style { get; set; } /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// Controls if a border should be displayed around the container. /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + [JsonPropertyName("showBorder")] + public bool? ShowBorder { get; set; } = false; /// - /// Must be **graph.microsoft.com/resource**. + /// Controls if the container should have rounded corners. /// - [JsonPropertyName("name")] - public string Name { get; } = "graph.microsoft.com/resource"; + [JsonPropertyName("roundedCorners")] + public bool? RoundedCorners { get; set; } = false; /// - /// The properties of the resource. + /// The layouts associated with the container. The container can dynamically switch from one layout to another as the card's width changes. See [Container layouts](https://adaptivecards.microsoft.com/?topic=container-layouts) for more details. /// - [JsonPropertyName("properties")] - public ResourceProperties? Properties { get; set; } + [JsonPropertyName("layouts")] + public IList? Layouts { get; set; } + + /// + /// The minimum height, in pixels, of the container, in the `px` format. + /// + [JsonPropertyName("minHeight")] + public string? MinHeight { get; set; } + + /// + /// Defines the container's background image. + /// + [JsonPropertyName("backgroundImage")] + public IUnion? BackgroundImage { get; set; } + + /// + /// Controls how the container's content should be vertically aligned. + /// + [JsonPropertyName("verticalContentAlignment")] + public VerticalAlignment? VerticalContentAlignment { get; set; } + + /// + /// Controls if the content of the card is to be rendered left-to-right or right-to-left. + /// + [JsonPropertyName("rtl")] + public bool? Rtl { get; set; } + + /// + /// The maximum height, in pixels, of the container, in the `px` format. When the content of a container exceeds the container's maximum height, a vertical scrollbar is displayed. + /// + [JsonPropertyName("maxHeight")] + public string? MaxHeight { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -13588,7 +16978,25 @@ public class ComResourceMicrosoftGraphComponent : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this ComResourceMicrosoftGraphComponent into a JSON string. + /// The elements in the page. + /// + [JsonPropertyName("items")] + public IList? Items { get; set; } + + public CarouselPage() { } + + public CarouselPage(params CardElement[] items) + { + this.Items = new List(items); + } + + public CarouselPage(IList items) + { + this.Items = items; + } + + /// + /// Serializes this CarouselPage into a JSON string. /// public string Serialize() { @@ -13602,209 +17010,169 @@ public string Serialize() ); } - public ComResourceMicrosoftGraphComponent WithId(string value) + public CarouselPage WithKey(string value) + { + this.Key = value; + return this; + } + + public CarouselPage WithId(string value) { this.Id = value; return this; } - public ComResourceMicrosoftGraphComponent WithRequires(HostCapabilities value) + public CarouselPage WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ComResourceMicrosoftGraphComponent WithLang(string value) + public CarouselPage WithLang(string value) { this.Lang = value; return this; } - public ComResourceMicrosoftGraphComponent WithIsVisible(bool value) + public CarouselPage WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ComResourceMicrosoftGraphComponent WithSeparator(bool value) + public CarouselPage WithHeight(ElementHeight value) { - this.Separator = value; + this.Height = value; return this; } - public ComResourceMicrosoftGraphComponent WithHeight(ElementHeight value) + public CarouselPage WithTargetWidth(TargetWidth value) { - this.Height = value; + this.TargetWidth = value; + return this; + } + + public CarouselPage WithIsSortKey(bool value) + { + this.IsSortKey = value; return this; } - public ComResourceMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) + public CarouselPage WithSelectAction(Action value) { - this.HorizontalAlignment = value; + this.SelectAction = value; return this; } - public ComResourceMicrosoftGraphComponent WithSpacing(Spacing value) + public CarouselPage WithStyle(ContainerStyle value) { - this.Spacing = value; + this.Style = value; return this; } - public ComResourceMicrosoftGraphComponent WithTargetWidth(TargetWidth value) + public CarouselPage WithShowBorder(bool value) { - this.TargetWidth = value; + this.ShowBorder = value; return this; } - public ComResourceMicrosoftGraphComponent WithIsSortKey(bool value) + public CarouselPage WithRoundedCorners(bool value) { - this.IsSortKey = value; + this.RoundedCorners = value; return this; } - public ComResourceMicrosoftGraphComponent WithProperties(ResourceProperties value) + public CarouselPage WithLayouts(params ContainerLayout[] value) { - this.Properties = value; + this.Layouts = new List(value); return this; } - public ComResourceMicrosoftGraphComponent WithGridArea(string value) + public CarouselPage WithLayouts(IList value) { - this.GridArea = value; + this.Layouts = value; return this; } - public ComResourceMicrosoftGraphComponent WithFallback(IUnion value) + public CarouselPage WithMinHeight(string value) { - this.Fallback = value; + this.MinHeight = value; return this; } -} -/// -/// Represents the properties of a resource component. -/// -public class ResourceProperties : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type ResourceProperties. - /// - public static ResourceProperties? Deserialize(string json) + public CarouselPage WithBackgroundImage(IUnion value) { - return JsonSerializer.Deserialize(json); + this.BackgroundImage = value; + return this; } - /// - /// The Id of the resource. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// The reference to the resource. - /// - [JsonPropertyName("resourceReference")] - public IDictionary? ResourceReference { get; set; } - - /// - /// The visualization of the resource. - /// - [JsonPropertyName("resourceVisualization")] - public ResourceVisualization? ResourceVisualization { get; set; } - - /// - /// Serializes this ResourceProperties into a JSON string. - /// - public string Serialize() + public CarouselPage WithVerticalContentAlignment(VerticalAlignment value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.VerticalContentAlignment = value; + return this; } - public ResourceProperties WithId(string value) + public CarouselPage WithRtl(bool value) { - this.Id = value; + this.Rtl = value; return this; } - public ResourceProperties WithResourceReference(IDictionary value) + public CarouselPage WithMaxHeight(string value) { - this.ResourceReference = value; + this.MaxHeight = value; return this; } - public ResourceProperties WithResourceVisualization(ResourceVisualization value) + public CarouselPage WithGridArea(string value) { - this.ResourceVisualization = value; + this.GridArea = value; return this; } -} -/// -/// Represents a visualization of a resource. -/// -public class ResourceVisualization : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type ResourceVisualization. - /// - public static ResourceVisualization? Deserialize(string json) + public CarouselPage WithFallback(IUnion value) { - return JsonSerializer.Deserialize(json); + this.Fallback = value; + return this; } - /// - /// The media associated with the resource. - /// - [JsonPropertyName("media")] - public string? Media { get; set; } - - /// - /// Serializes this ResourceVisualization into a JSON string. - /// - public string Serialize() + public CarouselPage WithItems(params CardElement[] value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Items = new List(value); + return this; } - public ResourceVisualization WithMedia(string value) + public CarouselPage WithItems(IList value) { - this.Media = value; + this.Items = value; return this; } } /// -/// Displays information about a file resource. +/// Represents a row of cells in a table. /// -public class ComFileMicrosoftGraphComponent : CardElement +public class TableRow : CardElement { /// - /// Deserializes a JSON string into an object of type ComFileMicrosoftGraphComponent. + /// Deserializes a JSON string into an object of type TableRow. /// - public static ComFileMicrosoftGraphComponent? Deserialize(string json) + public static TableRow? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Component**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **TableRow**. /// [JsonPropertyName("type")] - public string Type { get; } = "Component"; + public string Type { get; } = "TableRow"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -13816,7 +17184,7 @@ public class ComFileMicrosoftGraphComponent : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -13828,19 +17196,19 @@ public class ComFileMicrosoftGraphComponent : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls how the element should be horizontally aligned. @@ -13852,7 +17220,7 @@ public class ComFileMicrosoftGraphComponent : CardElement /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -13864,19 +17232,37 @@ public class ComFileMicrosoftGraphComponent : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// Must be **graph.microsoft.com/file**. + /// Controls if a border should be displayed around the container. /// - [JsonPropertyName("name")] - public string Name { get; } = "graph.microsoft.com/file"; + [JsonPropertyName("showBorder")] + public bool? ShowBorder { get; set; } = false; /// - /// The properties of the file. + /// Controls if the container should have rounded corners. /// - [JsonPropertyName("properties")] - public FileProperties? Properties { get; set; } + [JsonPropertyName("roundedCorners")] + public bool? RoundedCorners { get; set; } = false; + + /// + /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. + /// + [JsonPropertyName("style")] + public ContainerStyle? Style { get; set; } + + /// + /// Controls how the content of every cell in the row should be horizontally aligned by default. This property overrides the horizontalCellContentAlignment property of the table and columns. + /// + [JsonPropertyName("horizontalCellContentAlignment")] + public HorizontalAlignment? HorizontalCellContentAlignment { get; set; } + + /// + /// Controls how the content of every cell in the row should be vertically aligned by default. This property overrides the verticalCellContentAlignment property of the table and columns. + /// + [JsonPropertyName("verticalCellContentAlignment")] + public VerticalAlignment? VerticalCellContentAlignment { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -13891,7 +17277,13 @@ public class ComFileMicrosoftGraphComponent : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this ComFileMicrosoftGraphComponent into a JSON string. + /// The cells in the row. + /// + [JsonPropertyName("cells")] + public IList? Cells { get; set; } + + /// + /// Serializes this TableRow into a JSON string. /// public string Serialize() { @@ -13905,168 +17297,151 @@ public string Serialize() ); } - public ComFileMicrosoftGraphComponent WithId(string value) + public TableRow WithKey(string value) + { + this.Key = value; + return this; + } + + public TableRow WithId(string value) { this.Id = value; return this; } - public ComFileMicrosoftGraphComponent WithRequires(HostCapabilities value) + public TableRow WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ComFileMicrosoftGraphComponent WithLang(string value) + public TableRow WithLang(string value) { this.Lang = value; return this; } - public ComFileMicrosoftGraphComponent WithIsVisible(bool value) + public TableRow WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ComFileMicrosoftGraphComponent WithSeparator(bool value) + public TableRow WithSeparator(bool value) { this.Separator = value; return this; } - public ComFileMicrosoftGraphComponent WithHeight(ElementHeight value) + public TableRow WithHeight(ElementHeight value) { this.Height = value; return this; } - public ComFileMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) + public TableRow WithHorizontalAlignment(HorizontalAlignment value) { this.HorizontalAlignment = value; return this; } - public ComFileMicrosoftGraphComponent WithSpacing(Spacing value) + public TableRow WithSpacing(Spacing value) { this.Spacing = value; return this; } - public ComFileMicrosoftGraphComponent WithTargetWidth(TargetWidth value) + public TableRow WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public ComFileMicrosoftGraphComponent WithIsSortKey(bool value) + public TableRow WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public ComFileMicrosoftGraphComponent WithProperties(FileProperties value) + public TableRow WithShowBorder(bool value) { - this.Properties = value; + this.ShowBorder = value; return this; } - public ComFileMicrosoftGraphComponent WithGridArea(string value) + public TableRow WithRoundedCorners(bool value) { - this.GridArea = value; + this.RoundedCorners = value; return this; } - public ComFileMicrosoftGraphComponent WithFallback(IUnion value) + public TableRow WithStyle(ContainerStyle value) { - this.Fallback = value; + this.Style = value; return this; } -} -/// -/// Represents the properties of a file component. -/// -public class FileProperties : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type FileProperties. - /// - public static FileProperties? Deserialize(string json) + public TableRow WithHorizontalCellContentAlignment(HorizontalAlignment value) { - return JsonSerializer.Deserialize(json); + this.HorizontalCellContentAlignment = value; + return this; } - /// - /// The name of the file. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// The file extension. - /// - [JsonPropertyName("extension")] - public string? Extension { get; set; } - - /// - /// The URL of the file. - /// - [JsonPropertyName("url")] - public string? Url { get; set; } - - /// - /// Serializes this FileProperties into a JSON string. - /// - public string Serialize() + public TableRow WithVerticalCellContentAlignment(VerticalAlignment value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.VerticalCellContentAlignment = value; + return this; } - public FileProperties WithName(string value) + public TableRow WithGridArea(string value) { - this.Name = value; + this.GridArea = value; return this; } - public FileProperties WithExtension(string value) + public TableRow WithFallback(IUnion value) { - this.Extension = value; + this.Fallback = value; return this; } - public FileProperties WithUrl(string value) + public TableRow WithCells(params TableCell[] value) { - this.Url = value; + this.Cells = new List(value); + return this; + } + + public TableRow WithCells(IList value) + { + this.Cells = value; return this; } } /// -/// Displays information about a calendar event. +/// Represents a cell in a table row. /// -public class ComEventMicrosoftGraphComponent : CardElement +public class TableCell : CardElement { /// - /// Deserializes a JSON string into an object of type ComEventMicrosoftGraphComponent. + /// Deserializes a JSON string into an object of type TableCell. /// - public static ComEventMicrosoftGraphComponent? Deserialize(string json) + public static TableCell? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **Component**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **TableCell**. /// [JsonPropertyName("type")] - public string Type { get; } = "Component"; + public string Type { get; } = "TableCell"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -14078,7 +17453,7 @@ public class ComEventMicrosoftGraphComponent : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -14090,31 +17465,25 @@ public class ComEventMicrosoftGraphComponent : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls how the element should be horizontally aligned. - /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -14126,19 +17495,61 @@ public class ComEventMicrosoftGraphComponent : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// Must be **graph.microsoft.com/event**. + /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. /// - [JsonPropertyName("name")] - public string Name { get; } = "graph.microsoft.com/event"; + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } /// - /// The properties of the event. + /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. /// - [JsonPropertyName("properties")] - public CalendarEventProperties? Properties { get; set; } + [JsonPropertyName("style")] + public ContainerStyle? Style { get; set; } + + /// + /// The layouts associated with the container. The container can dynamically switch from one layout to another as the card's width changes. See [Container layouts](https://adaptivecards.microsoft.com/?topic=container-layouts) for more details. + /// + [JsonPropertyName("layouts")] + public IList? Layouts { get; set; } + + /// + /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. + /// + [JsonPropertyName("bleed")] + public bool? Bleed { get; set; } = false; + + /// + /// The minimum height, in pixels, of the container, in the `px` format. + /// + [JsonPropertyName("minHeight")] + public string? MinHeight { get; set; } + + /// + /// Defines the container's background image. + /// + [JsonPropertyName("backgroundImage")] + public IUnion? BackgroundImage { get; set; } + + /// + /// Controls how the container's content should be vertically aligned. + /// + [JsonPropertyName("verticalContentAlignment")] + public VerticalAlignment? VerticalContentAlignment { get; set; } + + /// + /// Controls if the content of the card is to be rendered left-to-right or right-to-left. + /// + [JsonPropertyName("rtl")] + public bool? Rtl { get; set; } + + /// + /// The maximum height, in pixels, of the container, in the `px` format. When the content of a container exceeds the container's maximum height, a vertical scrollbar is displayed. + /// + [JsonPropertyName("maxHeight")] + public string? MaxHeight { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -14153,7 +17564,25 @@ public class ComEventMicrosoftGraphComponent : CardElement public IUnion? Fallback { get; set; } /// - /// Serializes this ComEventMicrosoftGraphComponent into a JSON string. + /// The items (elements) in the cell. + /// + [JsonPropertyName("items")] + public IList? Items { get; set; } + + public TableCell() { } + + public TableCell(params CardElement[] items) + { + this.Items = new List(items); + } + + public TableCell(IList items) + { + this.Items = items; + } + + /// + /// Serializes this TableCell into a JSON string. /// public string Serialize() { @@ -14167,172 +17596,287 @@ public string Serialize() ); } - public ComEventMicrosoftGraphComponent WithId(string value) + public TableCell WithKey(string value) + { + this.Key = value; + return this; + } + + public TableCell WithId(string value) { this.Id = value; return this; } - public ComEventMicrosoftGraphComponent WithRequires(HostCapabilities value) + public TableCell WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public ComEventMicrosoftGraphComponent WithLang(string value) + public TableCell WithLang(string value) { this.Lang = value; return this; } - public ComEventMicrosoftGraphComponent WithIsVisible(bool value) + public TableCell WithIsVisible(bool value) { this.IsVisible = value; return this; } - public ComEventMicrosoftGraphComponent WithSeparator(bool value) + public TableCell WithSeparator(bool value) { this.Separator = value; return this; } - public ComEventMicrosoftGraphComponent WithHeight(ElementHeight value) + public TableCell WithHeight(ElementHeight value) { this.Height = value; return this; } - public ComEventMicrosoftGraphComponent WithHorizontalAlignment(HorizontalAlignment value) + public TableCell WithSpacing(Spacing value) { - this.HorizontalAlignment = value; + this.Spacing = value; return this; } - public ComEventMicrosoftGraphComponent WithSpacing(Spacing value) + public TableCell WithTargetWidth(TargetWidth value) { - this.Spacing = value; + this.TargetWidth = value; return this; } - public ComEventMicrosoftGraphComponent WithTargetWidth(TargetWidth value) + public TableCell WithIsSortKey(bool value) { - this.TargetWidth = value; + this.IsSortKey = value; return this; } - public ComEventMicrosoftGraphComponent WithIsSortKey(bool value) + public TableCell WithSelectAction(Action value) { - this.IsSortKey = value; + this.SelectAction = value; return this; } - public ComEventMicrosoftGraphComponent WithProperties(CalendarEventProperties value) + public TableCell WithStyle(ContainerStyle value) { - this.Properties = value; + this.Style = value; return this; } - public ComEventMicrosoftGraphComponent WithGridArea(string value) + public TableCell WithLayouts(params ContainerLayout[] value) + { + this.Layouts = new List(value); + return this; + } + + public TableCell WithLayouts(IList value) + { + this.Layouts = value; + return this; + } + + public TableCell WithBleed(bool value) + { + this.Bleed = value; + return this; + } + + public TableCell WithMinHeight(string value) + { + this.MinHeight = value; + return this; + } + + public TableCell WithBackgroundImage(IUnion value) + { + this.BackgroundImage = value; + return this; + } + + public TableCell WithVerticalContentAlignment(VerticalAlignment value) + { + this.VerticalContentAlignment = value; + return this; + } + + public TableCell WithRtl(bool value) + { + this.Rtl = value; + return this; + } + + public TableCell WithMaxHeight(string value) + { + this.MaxHeight = value; + return this; + } + + public TableCell WithGridArea(string value) { this.GridArea = value; return this; } - public ComEventMicrosoftGraphComponent WithFallback(IUnion value) + public TableCell WithFallback(IUnion value) { this.Fallback = value; return this; } + + public TableCell WithItems(params CardElement[] value) + { + this.Items = new List(value); + return this; + } + + public TableCell WithItems(IList value) + { + this.Items = value; + return this; + } } /// -/// The properties of a calendar event. +/// A block of text inside a RichTextBlock element. /// -public class CalendarEventProperties : SerializableObject +public class TextRun : CardElement { /// - /// Deserializes a JSON string into an object of type CalendarEventProperties. + /// Deserializes a JSON string into an object of type TextRun. /// - public static CalendarEventProperties? Deserialize(string json) + public static TextRun? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The ID of the event. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **TextRun**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "TextRun"; + + /// + /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. /// [JsonPropertyName("id")] public string? Id { get; set; } /// - /// The title of the event. + /// The locale associated with the element. /// - [JsonPropertyName("title")] - public string? Title { get; set; } + [JsonPropertyName("lang")] + public string? Lang { get; set; } + + /// + /// Controls the visibility of the element. + /// + [JsonPropertyName("isVisible")] + public bool? IsVisible { get; set; } = true; + + /// + /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// + [JsonPropertyName("isSortKey")] + public bool? IsSortKey { get; set; } = false; + + /// + /// The text to display. A subset of markdown is supported. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// The size of the text. + /// + [JsonPropertyName("size")] + public TextSize? Size { get; set; } + + /// + /// The weight of the text. + /// + [JsonPropertyName("weight")] + public TextWeight? Weight { get; set; } /// - /// The start date and time of the event. + /// The color of the text. /// - [JsonPropertyName("start")] - public string? Start { get; set; } + [JsonPropertyName("color")] + public TextColor? Color { get; set; } /// - /// The end date and time of the event. + /// Controls whether the text should be renderer using a subtler variant of the select color. /// - [JsonPropertyName("end")] - public string? End { get; set; } + [JsonPropertyName("isSubtle")] + public bool? IsSubtle { get; set; } /// - /// The status of the event. + /// The type of font to use for rendering. /// - [JsonPropertyName("status")] - public string? Status { get; set; } + [JsonPropertyName("fontType")] + public FontType? FontType { get; set; } /// - /// The locations of the event. + /// Controls if the text should be italicized. /// - [JsonPropertyName("locations")] - public IList? Locations { get; set; } + [JsonPropertyName("italic")] + public bool? Italic { get; set; } = false; /// - /// The URL of the online meeting. + /// Controls if the text should be struck through. /// - [JsonPropertyName("onlineMeetingUrl")] - public string? OnlineMeetingUrl { get; set; } + [JsonPropertyName("strikethrough")] + public bool? Strikethrough { get; set; } = false; /// - /// Indicates if the event is all day. + /// Controls if the text should be highlighted. /// - [JsonPropertyName("isAllDay")] - public bool? IsAllDay { get; set; } + [JsonPropertyName("highlight")] + public bool? Highlight { get; set; } = false; /// - /// The extension of the event. + /// Controls if the text should be underlined. /// - [JsonPropertyName("extension")] - public string? Extension { get; set; } + [JsonPropertyName("underline")] + public bool? Underline { get; set; } = false; /// - /// The URL of the event. + /// An Action that will be invoked when the text is tapped or clicked. Action.ShowCard is not supported. /// - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } /// - /// The attendees of the event. + /// The area of a Layout.AreaGrid layout in which an element should be displayed. /// - [JsonPropertyName("attendees")] - public IList? Attendees { get; set; } + [JsonPropertyName("grid.area")] + public string? GridArea { get; set; } /// - /// The organizer of the event. + /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. /// - [JsonPropertyName("organizer")] - public CalendarEventAttendee? Organizer { get; set; } + [JsonPropertyName("fallback")] + public IUnion? Fallback { get; set; } + + public TextRun() { } + + public TextRun(string text) + { + this.Text = text; + } /// - /// Serializes this CalendarEventProperties into a JSON string. + /// Serializes this TextRun into a JSON string. /// public string Serialize() { @@ -14346,186 +17890,139 @@ public string Serialize() ); } - public CalendarEventProperties WithId(string value) + public TextRun WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public CalendarEventProperties WithTitle(string value) + public TextRun WithId(string value) { - this.Title = value; + this.Id = value; return this; } - public CalendarEventProperties WithStart(string value) + public TextRun WithLang(string value) { - this.Start = value; + this.Lang = value; return this; } - public CalendarEventProperties WithEnd(string value) + public TextRun WithIsVisible(bool value) { - this.End = value; + this.IsVisible = value; return this; } - public CalendarEventProperties WithStatus(string value) + public TextRun WithIsSortKey(bool value) { - this.Status = value; + this.IsSortKey = value; return this; } - public CalendarEventProperties WithLocations(params IList value) + public TextRun WithText(string value) { - this.Locations = value; + this.Text = value; return this; } - public CalendarEventProperties WithOnlineMeetingUrl(string value) + public TextRun WithSize(TextSize value) { - this.OnlineMeetingUrl = value; + this.Size = value; return this; } - public CalendarEventProperties WithIsAllDay(bool value) + public TextRun WithWeight(TextWeight value) { - this.IsAllDay = value; + this.Weight = value; return this; } - public CalendarEventProperties WithExtension(string value) + public TextRun WithColor(TextColor value) { - this.Extension = value; + this.Color = value; return this; } - public CalendarEventProperties WithUrl(string value) + public TextRun WithIsSubtle(bool value) { - this.Url = value; + this.IsSubtle = value; return this; } - public CalendarEventProperties WithAttendees(params IList value) + public TextRun WithFontType(FontType value) { - this.Attendees = value; + this.FontType = value; return this; } - public CalendarEventProperties WithOrganizer(CalendarEventAttendee value) + public TextRun WithItalic(bool value) { - this.Organizer = value; + this.Italic = value; return this; } -} - -/// -/// Represents a calendar event attendee. -/// -public class CalendarEventAttendee : SerializableObject -{ - /// - /// Deserializes a JSON string into an object of type CalendarEventAttendee. - /// - public static CalendarEventAttendee? Deserialize(string json) - { - return JsonSerializer.Deserialize(json); - } - - /// - /// The name of the attendee. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// The email address of the attendee. - /// - [JsonPropertyName("email")] - public string? Email { get; set; } - - /// - /// The title of the attendee. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// The type of the attendee. - /// - [JsonPropertyName("type")] - public string? Type { get; set; } - - /// - /// The status of the attendee. - /// - [JsonPropertyName("status")] - public string? Status { get; set; } - /// - /// Serializes this CalendarEventAttendee into a JSON string. - /// - public string Serialize() + public TextRun WithStrikethrough(bool value) { - return JsonSerializer.Serialize( - this, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - } - ); + this.Strikethrough = value; + return this; } - public CalendarEventAttendee WithName(string value) + public TextRun WithHighlight(bool value) { - this.Name = value; + this.Highlight = value; return this; } - public CalendarEventAttendee WithEmail(string value) + public TextRun WithUnderline(bool value) { - this.Email = value; + this.Underline = value; return this; } - public CalendarEventAttendee WithTitle(string value) + public TextRun WithSelectAction(Action value) { - this.Title = value; + this.SelectAction = value; return this; } - public CalendarEventAttendee WithType(string value) + public TextRun WithGridArea(string value) { - this.Type = value; + this.GridArea = value; return this; } - public CalendarEventAttendee WithStatus(string value) + public TextRun WithFallback(IUnion value) { - this.Status = value; + this.Fallback = value; return this; } } /// -/// A page inside a Carousel element. +/// An inline icon inside a RichTextBlock element. /// -public class CarouselPage : CardElement +public class IconRun : CardElement { /// - /// Deserializes a JSON string into an object of type CarouselPage. + /// Deserializes a JSON string into an object of type IconRun. /// - public static CarouselPage? Deserialize(string json) + public static IconRun? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **CarouselPage**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **IconRun**. /// [JsonPropertyName("type")] - public string Type { get; } = "CarouselPage"; + public string Type { get; } = "IconRun"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -14533,12 +18030,6 @@ public class CarouselPage : CardElement [JsonPropertyName("id")] public string? Id { get; set; } - /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). - /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } - /// /// The locale associated with the element. /// @@ -14549,85 +18040,43 @@ public class CarouselPage : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } - - /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. - /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). - /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. - /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } - - /// - /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. - /// - [JsonPropertyName("style")] - public ContainerStyle? Style { get; set; } - - /// - /// Controls if a border should be displayed around the container. - /// - [JsonPropertyName("showBorder")] - public bool? ShowBorder { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// Controls if the container should have rounded corners. - /// - [JsonPropertyName("roundedCorners")] - public bool? RoundedCorners { get; set; } - - /// - /// The layouts associated with the container. The container can dynamically switch from one layout to another as the card's width changes. See [Container layouts](https://adaptivecards.microsoft.com/?topic=container-layouts) for more details. - /// - [JsonPropertyName("layouts")] - public IList? Layouts { get; set; } - - /// - /// The minimum height, in pixels, of the container, in the `px` format. + /// The name of the inline icon to display. /// - [JsonPropertyName("minHeight")] - public string? MinHeight { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// - /// Defines the container's background image. + /// The size of the inline icon. /// - [JsonPropertyName("backgroundImage")] - public IUnion? BackgroundImage { get; set; } + [JsonPropertyName("size")] + public SizeEnum? Size { get; set; } = SizeEnum.Default; /// - /// Controls how the container's content should be vertically aligned. + /// The style of the inline icon. /// - [JsonPropertyName("verticalContentAlignment")] - public VerticalAlignment? VerticalContentAlignment { get; set; } + [JsonPropertyName("style")] + public IconStyle? Style { get; set; } = IconStyle.Regular; /// - /// Controls if the content of the card is to be rendered left-to-right or right-to-left. + /// The color of the inline icon. /// - [JsonPropertyName("rtl")] - public bool? Rtl { get; set; } + [JsonPropertyName("color")] + public TextColor? Color { get; set; } = TextColor.Default; /// - /// The maximum height, in pixels, of the container, in the `px` format. When the content of a container exceeds the container's maximum height, a vertical scrollbar is displayed. + /// An Action that will be invoked when the inline icon is tapped or clicked. Action.ShowCard is not supported. /// - [JsonPropertyName("maxHeight")] - public string? MaxHeight { get; set; } + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -14642,18 +18091,7 @@ public class CarouselPage : CardElement public IUnion? Fallback { get; set; } /// - /// The elements in the page. - /// - [JsonPropertyName("items")] - public IList? Items { get; set; } - - public CarouselPage(params IList items) - { - this.Items = items; - } - - /// - /// Serializes this CarouselPage into a JSON string. + /// Serializes this IconRun into a JSON string. /// public string Serialize() { @@ -14667,145 +18105,103 @@ public string Serialize() ); } - public CarouselPage WithId(string value) + public IconRun WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public CarouselPage WithRequires(HostCapabilities value) + public IconRun WithId(string value) { - this.Requires = value; + this.Id = value; return this; } - public CarouselPage WithLang(string value) + public IconRun WithLang(string value) { this.Lang = value; return this; } - public CarouselPage WithIsVisible(bool value) + public IconRun WithIsVisible(bool value) { this.IsVisible = value; return this; } - public CarouselPage WithHeight(ElementHeight value) - { - this.Height = value; - return this; - } - - public CarouselPage WithTargetWidth(TargetWidth value) - { - this.TargetWidth = value; - return this; - } - - public CarouselPage WithIsSortKey(bool value) + public IconRun WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public CarouselPage WithSelectAction(Action value) - { - this.SelectAction = value; - return this; - } - - public CarouselPage WithStyle(ContainerStyle value) - { - this.Style = value; - return this; - } - - public CarouselPage WithShowBorder(bool value) - { - this.ShowBorder = value; - return this; - } - - public CarouselPage WithRoundedCorners(bool value) - { - this.RoundedCorners = value; - return this; - } - - public CarouselPage WithLayouts(params IList value) - { - this.Layouts = value; - return this; - } - - public CarouselPage WithMinHeight(string value) + public IconRun WithName(string value) { - this.MinHeight = value; + this.Name = value; return this; } - public CarouselPage WithBackgroundImage(IUnion value) + public IconRun WithSize(SizeEnum value) { - this.BackgroundImage = value; + this.Size = value; return this; } - public CarouselPage WithVerticalContentAlignment(VerticalAlignment value) + public IconRun WithStyle(IconStyle value) { - this.VerticalContentAlignment = value; + this.Style = value; return this; } - public CarouselPage WithRtl(bool value) + public IconRun WithColor(TextColor value) { - this.Rtl = value; + this.Color = value; return this; } - public CarouselPage WithMaxHeight(string value) + public IconRun WithSelectAction(Action value) { - this.MaxHeight = value; + this.SelectAction = value; return this; } - public CarouselPage WithGridArea(string value) + public IconRun WithGridArea(string value) { this.GridArea = value; return this; } - public CarouselPage WithFallback(IUnion value) + public IconRun WithFallback(IUnion value) { this.Fallback = value; return this; } - - public CarouselPage WithItems(params IList value) - { - this.Items = value; - return this; - } } /// -/// Represents a row of cells in a table. +/// An inline image inside a RichTextBlock element. /// -public class TableRow : CardElement +public class ImageRun : CardElement { /// - /// Deserializes a JSON string into an object of type TableRow. + /// Deserializes a JSON string into an object of type ImageRun. /// - public static TableRow? Deserialize(string json) + public static ImageRun? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **TableRow**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Must be **ImageRun**. /// [JsonPropertyName("type")] - public string Type { get; } = "TableRow"; + public string Type { get; } = "ImageRun"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -14813,12 +18209,6 @@ public class TableRow : CardElement [JsonPropertyName("id")] public string? Id { get; set; } - /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). - /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } - /// /// The locale associated with the element. /// @@ -14829,73 +18219,43 @@ public class TableRow : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } - - /// - /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. - /// - [JsonPropertyName("separator")] - public bool? Separator { get; set; } - - /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. - /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } - - /// - /// Controls how the element should be horizontally aligned. - /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } - - /// - /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. - /// - [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } - - /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). - /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// - /// Controls if a border should be displayed around the container. + /// The URL (or Base64-encoded Data URI) of the image. Acceptable formats are PNG, JPEG, GIF and SVG. /// - [JsonPropertyName("showBorder")] - public bool? ShowBorder { get; set; } + [JsonPropertyName("url")] + public string? Url { get; set; } /// - /// Controls if the container should have rounded corners. + /// The size of the inline image. /// - [JsonPropertyName("roundedCorners")] - public bool? RoundedCorners { get; set; } + [JsonPropertyName("size")] + public SizeEnum? Size { get; set; } = SizeEnum.Default; /// - /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. + /// The style of the inline image. /// [JsonPropertyName("style")] - public ContainerStyle? Style { get; set; } + public ImageStyle? Style { get; set; } = ImageStyle.Default; /// - /// Controls how the content of every cell in the row should be horizontally aligned by default. This property overrides the horizontalCellContentAlignment property of the table and columns. + /// An Action that will be invoked when the image is tapped or clicked. Action.ShowCard is not supported. /// - [JsonPropertyName("horizontalCellContentAlignment")] - public HorizontalAlignment? HorizontalCellContentAlignment { get; set; } + [JsonPropertyName("selectAction")] + public Action? SelectAction { get; set; } /// - /// Controls how the content of every cell in the row should be vertically aligned by default. This property overrides the verticalCellContentAlignment property of the table and columns. + /// A set of theme-specific image URLs. /// - [JsonPropertyName("verticalCellContentAlignment")] - public VerticalAlignment? VerticalCellContentAlignment { get; set; } + [JsonPropertyName("themedUrls")] + public IList? ThemedUrls { get; set; } /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. @@ -14910,13 +18270,7 @@ public class TableRow : CardElement public IUnion? Fallback { get; set; } /// - /// The cells in the row. - /// - [JsonPropertyName("cells")] - public IList? Cells { get; set; } - - /// - /// Serializes this TableRow into a JSON string. + /// Serializes this ImageRun into a JSON string. /// public string Serialize() { @@ -14930,133 +18284,109 @@ public string Serialize() ); } - public TableRow WithId(string value) + public ImageRun WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public TableRow WithRequires(HostCapabilities value) + public ImageRun WithId(string value) { - this.Requires = value; + this.Id = value; return this; } - public TableRow WithLang(string value) + public ImageRun WithLang(string value) { this.Lang = value; return this; } - public TableRow WithIsVisible(bool value) + public ImageRun WithIsVisible(bool value) { this.IsVisible = value; return this; } - public TableRow WithSeparator(bool value) - { - this.Separator = value; - return this; - } - - public TableRow WithHeight(ElementHeight value) - { - this.Height = value; - return this; - } - - public TableRow WithHorizontalAlignment(HorizontalAlignment value) - { - this.HorizontalAlignment = value; - return this; - } - - public TableRow WithSpacing(Spacing value) - { - this.Spacing = value; - return this; - } - - public TableRow WithTargetWidth(TargetWidth value) - { - this.TargetWidth = value; - return this; - } - - public TableRow WithIsSortKey(bool value) + public ImageRun WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public TableRow WithShowBorder(bool value) + public ImageRun WithUrl(string value) { - this.ShowBorder = value; + this.Url = value; return this; } - public TableRow WithRoundedCorners(bool value) + public ImageRun WithSize(SizeEnum value) { - this.RoundedCorners = value; + this.Size = value; return this; } - public TableRow WithStyle(ContainerStyle value) + public ImageRun WithStyle(ImageStyle value) { this.Style = value; return this; } - public TableRow WithHorizontalCellContentAlignment(HorizontalAlignment value) + public ImageRun WithSelectAction(Action value) { - this.HorizontalCellContentAlignment = value; + this.SelectAction = value; return this; } - public TableRow WithVerticalCellContentAlignment(VerticalAlignment value) + public ImageRun WithThemedUrls(params ThemedUrl[] value) { - this.VerticalCellContentAlignment = value; + this.ThemedUrls = new List(value); return this; } - public TableRow WithGridArea(string value) + public ImageRun WithThemedUrls(IList value) { - this.GridArea = value; + this.ThemedUrls = value; return this; } - public TableRow WithFallback(IUnion value) + public ImageRun WithGridArea(string value) { - this.Fallback = value; + this.GridArea = value; return this; } - public TableRow WithCells(params IList value) + public ImageRun WithFallback(IUnion value) { - this.Cells = value; + this.Fallback = value; return this; } } /// -/// Represents a cell in a table row. +/// A column in a ColumnSet element. /// -public class TableCell : CardElement +public class Column : CardElement { /// - /// Deserializes a JSON string into an object of type TableCell. + /// Deserializes a JSON string into an object of type Column. /// - public static TableCell? Deserialize(string json) + public static Column? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **TableCell**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Optional. If specified, must be **Column**. /// [JsonPropertyName("type")] - public string Type { get; } = "TableCell"; + public string Type { get; } = "Column"; /// /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. @@ -15068,7 +18398,7 @@ public class TableCell : CardElement /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). /// [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } + public HostCapabilities? Requires { get; set; } = new HostCapabilities(); /// /// The locale associated with the element. @@ -15080,25 +18410,31 @@ public class TableCell : CardElement /// Controls the visibility of the element. /// [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + public bool? IsVisible { get; set; } = true; /// /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. /// [JsonPropertyName("separator")] - public bool? Separator { get; set; } + public bool? Separator { get; set; } = false; /// /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. /// [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + public ElementHeight? Height { get; set; } = ElementHeight.Auto; + + /// + /// Controls how the element should be horizontally aligned. + /// + [JsonPropertyName("horizontalAlignment")] + public HorizontalAlignment? HorizontalAlignment { get; set; } /// /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. /// [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public Spacing? Spacing { get; set; } = Spacing.Default; /// /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). @@ -15110,7 +18446,7 @@ public class TableCell : CardElement /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. /// [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public bool? IsSortKey { get; set; } = false; /// /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. @@ -15124,6 +18460,18 @@ public class TableCell : CardElement [JsonPropertyName("style")] public ContainerStyle? Style { get; set; } + /// + /// Controls if a border should be displayed around the container. + /// + [JsonPropertyName("showBorder")] + public bool? ShowBorder { get; set; } = false; + + /// + /// Controls if the container should have rounded corners. + /// + [JsonPropertyName("roundedCorners")] + public bool? RoundedCorners { get; set; } = false; + /// /// The layouts associated with the container. The container can dynamically switch from one layout to another as the card's width changes. See [Container layouts](https://adaptivecards.microsoft.com/?topic=container-layouts) for more details. /// @@ -15134,7 +18482,7 @@ public class TableCell : CardElement /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. /// [JsonPropertyName("bleed")] - public bool? Bleed { get; set; } + public bool? Bleed { get; set; } = false; /// /// The minimum height, in pixels, of the container, in the `px` format. @@ -15166,6 +18514,12 @@ public class TableCell : CardElement [JsonPropertyName("maxHeight")] public string? MaxHeight { get; set; } + /// + /// The width of the column. If expressed as a number, represents the relative weight of the column in the set. If expressed as a string, `auto` will automatically adjust the column's width according to its content, `stretch` will make the column use the remaining horizontal space (shared with other columns with width set to `stretch`) and using the `px` format will give the column an explicit width in pixels. + /// + [JsonPropertyName("width")] + public IUnion? Width { get; set; } + /// /// The area of a Layout.AreaGrid layout in which an element should be displayed. /// @@ -15179,18 +18533,25 @@ public class TableCell : CardElement public IUnion? Fallback { get; set; } /// - /// The items (elements) in the cell. + /// The elements in the column. /// [JsonPropertyName("items")] public IList? Items { get; set; } - public TableCell(params IList items) + public Column() { } + + public Column(params CardElement[] items) + { + this.Items = new List(items); + } + + public Column(IList items) { this.Items = items; } /// - /// Serializes this TableCell into a JSON string. + /// Serializes this Column into a JSON string. /// public string Serialize() { @@ -15204,127 +18565,169 @@ public string Serialize() ); } - public TableCell WithId(string value) + public Column WithKey(string value) + { + this.Key = value; + return this; + } + + public Column WithId(string value) { this.Id = value; return this; } - public TableCell WithRequires(HostCapabilities value) + public Column WithRequires(HostCapabilities value) { this.Requires = value; return this; } - public TableCell WithLang(string value) + public Column WithLang(string value) { this.Lang = value; return this; } - public TableCell WithIsVisible(bool value) + public Column WithIsVisible(bool value) { this.IsVisible = value; return this; } - public TableCell WithSeparator(bool value) + public Column WithSeparator(bool value) { this.Separator = value; return this; } - public TableCell WithHeight(ElementHeight value) + public Column WithHeight(ElementHeight value) { this.Height = value; return this; } - public TableCell WithSpacing(Spacing value) + public Column WithHorizontalAlignment(HorizontalAlignment value) + { + this.HorizontalAlignment = value; + return this; + } + + public Column WithSpacing(Spacing value) { this.Spacing = value; return this; } - public TableCell WithTargetWidth(TargetWidth value) + public Column WithTargetWidth(TargetWidth value) { this.TargetWidth = value; return this; } - public TableCell WithIsSortKey(bool value) + public Column WithIsSortKey(bool value) { this.IsSortKey = value; return this; } - public TableCell WithSelectAction(Action value) + public Column WithSelectAction(Action value) { this.SelectAction = value; return this; } - public TableCell WithStyle(ContainerStyle value) + public Column WithStyle(ContainerStyle value) { this.Style = value; return this; } - public TableCell WithLayouts(params IList value) + public Column WithShowBorder(bool value) + { + this.ShowBorder = value; + return this; + } + + public Column WithRoundedCorners(bool value) + { + this.RoundedCorners = value; + return this; + } + + public Column WithLayouts(params ContainerLayout[] value) + { + this.Layouts = new List(value); + return this; + } + + public Column WithLayouts(IList value) { this.Layouts = value; return this; } - public TableCell WithBleed(bool value) + public Column WithBleed(bool value) { this.Bleed = value; return this; } - public TableCell WithMinHeight(string value) + public Column WithMinHeight(string value) { this.MinHeight = value; return this; } - public TableCell WithBackgroundImage(IUnion value) + public Column WithBackgroundImage(IUnion value) { this.BackgroundImage = value; return this; } - public TableCell WithVerticalContentAlignment(VerticalAlignment value) + public Column WithVerticalContentAlignment(VerticalAlignment value) { this.VerticalContentAlignment = value; return this; } - public TableCell WithRtl(bool value) + public Column WithRtl(bool value) { this.Rtl = value; return this; } - public TableCell WithMaxHeight(string value) + public Column WithMaxHeight(string value) { this.MaxHeight = value; return this; } - public TableCell WithGridArea(string value) + public Column WithWidth(IUnion value) + { + this.Width = value; + return this; + } + + public Column WithGridArea(string value) { this.GridArea = value; return this; } - public TableCell WithFallback(IUnion value) + public Column WithFallback(IUnion value) { this.Fallback = value; return this; } - public TableCell WithItems(params IList value) + public Column WithItems(params CardElement[] value) + { + this.Items = new List(value); + return this; + } + + public Column WithItems(IList value) { this.Items = value; return this; @@ -15332,133 +18735,100 @@ public TableCell WithItems(params IList value) } /// -/// A block of text inside a RichTextBlock element. +/// Represents the data of an Action.Submit. /// -public class TextRun : CardElement +public class SubmitActionData : SerializableObject { /// - /// Deserializes a JSON string into an object of type TextRun. + /// Deserializes a JSON string into an object of type SubmitActionData. /// - public static TextRun? Deserialize(string json) + public static SubmitActionData? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **TextRun**. - /// - [JsonPropertyName("type")] - public string Type { get; } = "TextRun"; - - /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// The locale associated with the element. - /// - [JsonPropertyName("lang")] - public string? Lang { get; set; } - - /// - /// Controls the visibility of the element. - /// - [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } - - /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. - /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } - - /// - /// The text to display. A subset of markdown is supported. - /// - [JsonPropertyName("text")] - public string? Text { get; set; } - - /// - /// The size of the text. - /// - [JsonPropertyName("size")] - public TextSize? Size { get; set; } - - /// - /// The weight of the text. - /// - [JsonPropertyName("weight")] - public TextWeight? Weight { get; set; } - - /// - /// The color of the text. - /// - [JsonPropertyName("color")] - public TextColor? Color { get; set; } - - /// - /// Controls whether the text should be renderer using a subtler variant of the select color. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("isSubtle")] - public bool? IsSubtle { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The type of font to use for rendering. + /// Defines the optional Teams-specific portion of the action's data. /// - [JsonPropertyName("fontType")] - public FontType? FontType { get; set; } + [JsonPropertyName("msteams")] + public object? Msteams { get; set; } /// - /// Controls if the text should be italicized. + /// Serializes this SubmitActionData into a JSON string. /// - [JsonPropertyName("italic")] - public bool? Italic { get; set; } + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } - /// - /// Controls if the text should be struck through. - /// - [JsonPropertyName("strikethrough")] - public bool? Strikethrough { get; set; } + public SubmitActionData WithKey(string value) + { + this.Key = value; + return this; + } - /// - /// Controls if the text should be highlighted. - /// - [JsonPropertyName("highlight")] - public bool? Highlight { get; set; } + public SubmitActionData WithMsteams(object value) + { + this.Msteams = value; + return this; + } + [JsonExtensionData] + public IDictionary NonSchemaProperties { get; set; } = new Dictionary(); +} +/// +/// Represents Teams-specific data in an Action.Submit to send an Instant Message back to the Bot. +/// +public class ImBackSubmitActionData : SerializableObject +{ /// - /// Controls if the text should be underlined. + /// Deserializes a JSON string into an object of type ImBackSubmitActionData. /// - [JsonPropertyName("underline")] - public bool? Underline { get; set; } + public static ImBackSubmitActionData? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } /// - /// An Action that will be invoked when the text is tapped or clicked. Action.ShowCard is not supported. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The area of a Layout.AreaGrid layout in which an element should be displayed. - /// - [JsonPropertyName("grid.area")] - public string? GridArea { get; set; } + /// Must be **imBack**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "imBack"; /// - /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The value that will be sent to the Bot. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("value")] + public string? Value { get; set; } - public TextRun(string text) + public ImBackSubmitActionData() { } + + public ImBackSubmitActionData(string value) { - this.Text = text; + this.Value = value; } /// - /// Serializes this TextRun into a JSON string. + /// Serializes this ImBackSubmitActionData into a JSON string. /// public string Serialize() { @@ -15472,196 +18842,332 @@ public string Serialize() ); } - public TextRun WithId(string value) + public ImBackSubmitActionData WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public TextRun WithLang(string value) + public ImBackSubmitActionData WithValue(string value) { - this.Lang = value; + this.Value = value; return this; } +} - public TextRun WithIsVisible(bool value) +/// +/// Represents Teams-specific data in an Action.Submit to make an Invoke request to the Bot. +/// +public class InvokeSubmitActionData : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type InvokeSubmitActionData. + /// + public static InvokeSubmitActionData? Deserialize(string json) { - this.IsVisible = value; - return this; + return JsonSerializer.Deserialize(json); } - public TextRun WithIsSortKey(bool value) - { - this.IsSortKey = value; - return this; - } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } - public TextRun WithText(string value) + /// + /// Must be **invoke**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "invoke"; + + /// + /// The object to send to the Bot with the Invoke request. Can be strongly typed as one of the below values to trigger a specific action in Teams. + /// + [JsonPropertyName("value")] + public IUnion? Value { get; set; } + + public InvokeSubmitActionData() { } + + public InvokeSubmitActionData(IUnion value) { - this.Text = value; - return this; + this.Value = value; } - public TextRun WithSize(TextSize value) + /// + /// Serializes this InvokeSubmitActionData into a JSON string. + /// + public string Serialize() { - this.Size = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public TextRun WithWeight(TextWeight value) + public InvokeSubmitActionData WithKey(string value) { - this.Weight = value; + this.Key = value; return this; } - public TextRun WithColor(TextColor value) + public InvokeSubmitActionData WithValue(IUnion value) { - this.Color = value; + this.Value = value; return this; } +} - public TextRun WithIsSubtle(bool value) +/// +/// Data for invoking a collaboration stage action. +/// +public class CollabStageInvokeDataValue : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type CollabStageInvokeDataValue. + /// + public static CollabStageInvokeDataValue? Deserialize(string json) { - this.IsSubtle = value; - return this; + return JsonSerializer.Deserialize(json); } - public TextRun WithFontType(FontType value) + /// + /// Must be **tab/tabInfoAction**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "tab/tabInfoAction"; + + /// + /// Provides information about the iFrame content, rendered in the collab stage popout window. + /// + [JsonPropertyName("tabInfo")] + public TabInfo? TabInfo { get; set; } + + /// + /// Serializes this CollabStageInvokeDataValue into a JSON string. + /// + public string Serialize() { - this.FontType = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public TextRun WithItalic(bool value) + public CollabStageInvokeDataValue WithTabInfo(TabInfo value) { - this.Italic = value; + this.TabInfo = value; return this; } +} - public TextRun WithStrikethrough(bool value) +/// +/// Represents information about the iFrame content, rendered in the collab stage popout window. +/// +public class TabInfo : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type TabInfo. + /// + public static TabInfo? Deserialize(string json) { - this.Strikethrough = value; - return this; + return JsonSerializer.Deserialize(json); } - public TextRun WithHighlight(bool value) + /// + /// The name for the content. This will be displayed as the title of the window hosting the iFrame. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The URL to open in an iFrame. + /// + [JsonPropertyName("contentUrl")] + public string? ContentUrl { get; set; } + + /// + /// The unique entity id for this content (e.g., random UUID). + /// + [JsonPropertyName("entityId")] + public string? EntityId { get; set; } + + /// + /// An optional website URL to the content, allowing users to open this content in the browser (if they prefer). + /// + [JsonPropertyName("websiteUrl")] + public string? WebsiteUrl { get; set; } + + /// + /// Serializes this TabInfo into a JSON string. + /// + public string Serialize() { - this.Highlight = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public TextRun WithUnderline(bool value) + public TabInfo WithName(string value) { - this.Underline = value; + this.Name = value; return this; } - public TextRun WithSelectAction(Action value) + public TabInfo WithContentUrl(string value) { - this.SelectAction = value; + this.ContentUrl = value; return this; } - public TextRun WithGridArea(string value) + public TabInfo WithEntityId(string value) { - this.GridArea = value; + this.EntityId = value; return this; } - public TextRun WithFallback(IUnion value) + public TabInfo WithWebsiteUrl(string value) { - this.Fallback = value; + this.WebsiteUrl = value; return this; } } /// -/// An inline icon inside a RichTextBlock element. +/// Represents Teams-specific data in an Action.Submit to send a message back to the Bot. /// -public class IconRun : CardElement +public class MessageBackSubmitActionData : SerializableObject { /// - /// Deserializes a JSON string into an object of type IconRun. + /// Deserializes a JSON string into an object of type MessageBackSubmitActionData. /// - public static IconRun? Deserialize(string json) + public static MessageBackSubmitActionData? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **IconRun**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("type")] - public string Type { get; } = "IconRun"; + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// Must be **messageBack**. /// - [JsonPropertyName("id")] - public string? Id { get; set; } + [JsonPropertyName("type")] + public string Type { get; } = "messageBack"; /// - /// The locale associated with the element. + /// The text that will be sent to the Bot. /// - [JsonPropertyName("lang")] - public string? Lang { get; set; } + [JsonPropertyName("text")] + public string? Text { get; set; } /// - /// Controls the visibility of the element. + /// The optional text that will be displayed as a new message in the conversation, as if the end-user sent it. `displayText` is not sent to the Bot. /// - [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + [JsonPropertyName("displayText")] + public string? DisplayText { get; set; } /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// Optional additional value that will be sent to the Bot. For instance, `value` can encode specific context for the action, such as unique identifiers or a JSON object. /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + [JsonPropertyName("value")] + public object? Value { get; set; } /// - /// The name of the inline icon to display. + /// Serializes this MessageBackSubmitActionData into a JSON string. /// - [JsonPropertyName("name")] - public string? Name { get; set; } + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } - /// - /// The size of the inline icon. - /// - [JsonPropertyName("size")] - public SizeEnum? Size { get; set; } + public MessageBackSubmitActionData WithKey(string value) + { + this.Key = value; + return this; + } - /// - /// The style of the inline icon. - /// - [JsonPropertyName("style")] - public IconStyle? Style { get; set; } + public MessageBackSubmitActionData WithText(string value) + { + this.Text = value; + return this; + } + + public MessageBackSubmitActionData WithDisplayText(string value) + { + this.DisplayText = value; + return this; + } + + public MessageBackSubmitActionData WithValue(object value) + { + this.Value = value; + return this; + } +} +/// +/// Represents Teams-specific data in an Action.Submit to sign in a user. +/// +public class SigninSubmitActionData : SerializableObject +{ /// - /// The color of the inline icon. + /// Deserializes a JSON string into an object of type SigninSubmitActionData. /// - [JsonPropertyName("color")] - public TextColor? Color { get; set; } + public static SigninSubmitActionData? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } /// - /// An Action that will be invoked when the inline icon is tapped or clicked. Action.ShowCard is not supported. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The area of a Layout.AreaGrid layout in which an element should be displayed. + /// Must be **signin**. /// - [JsonPropertyName("grid.area")] - public string? GridArea { get; set; } + [JsonPropertyName("type")] + public string Type { get; } = "signin"; /// - /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The URL to redirect the end-user for signing in. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("value")] + public string? Value { get; set; } + + public SigninSubmitActionData() { } + + public SigninSubmitActionData(string value) + { + this.Value = value; + } /// - /// Serializes this IconRun into a JSON string. + /// Serializes this SigninSubmitActionData into a JSON string. /// public string Serialize() { @@ -15675,160 +19181,205 @@ public string Serialize() ); } - public IconRun WithId(string value) + public SigninSubmitActionData WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public IconRun WithLang(string value) + public SigninSubmitActionData WithValue(string value) { - this.Lang = value; + this.Value = value; return this; } +} - public IconRun WithIsVisible(bool value) +/// +/// Represents Teams-specific data in an Action.Submit to open a task module. +/// +public class TaskFetchSubmitActionData : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type TaskFetchSubmitActionData. + /// + public static TaskFetchSubmitActionData? Deserialize(string json) { - this.IsVisible = value; - return this; + return JsonSerializer.Deserialize(json); } - public IconRun WithIsSortKey(bool value) - { - this.IsSortKey = value; - return this; - } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } - public IconRun WithName(string value) - { - this.Name = value; - return this; - } + /// + /// Must be **task/fetch**. + /// + [JsonPropertyName("type")] + public string Type { get; } = "task/fetch"; - public IconRun WithSize(SizeEnum value) + /// + /// Serializes this TaskFetchSubmitActionData into a JSON string. + /// + public string Serialize() { - this.Size = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public IconRun WithStyle(IconStyle value) + public TaskFetchSubmitActionData WithKey(string value) { - this.Style = value; + this.Key = value; return this; } +} - public IconRun WithColor(TextColor value) +/// +/// Teams-specific properties associated with the action. +/// +public class TeamsSubmitActionProperties : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type TeamsSubmitActionProperties. + /// + public static TeamsSubmitActionProperties? Deserialize(string json) { - this.Color = value; - return this; + return JsonSerializer.Deserialize(json); } - public IconRun WithSelectAction(Action value) + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// Defines how feedback is provided to the end-user when the action is executed. + /// + [JsonPropertyName("feedback")] + public TeamsSubmitActionFeedback? Feedback { get; set; } + + /// + /// Serializes this TeamsSubmitActionProperties into a JSON string. + /// + public string Serialize() { - this.SelectAction = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public IconRun WithGridArea(string value) + public TeamsSubmitActionProperties WithKey(string value) { - this.GridArea = value; + this.Key = value; return this; } - public IconRun WithFallback(IUnion value) + public TeamsSubmitActionProperties WithFeedback(TeamsSubmitActionFeedback value) { - this.Fallback = value; + this.Feedback = value; return this; } } /// -/// An inline image inside a RichTextBlock element. +/// Represents feedback options for an [Action.Submit](https://adaptivecards.microsoft.com/?topic=Action.Submit). /// -public class ImageRun : CardElement +public class TeamsSubmitActionFeedback : SerializableObject { /// - /// Deserializes a JSON string into an object of type ImageRun. + /// Deserializes a JSON string into an object of type TeamsSubmitActionFeedback. /// - public static ImageRun? Deserialize(string json) + public static TeamsSubmitActionFeedback? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Must be **ImageRun**. - /// - [JsonPropertyName("type")] - public string Type { get; } = "ImageRun"; - - /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. - /// - [JsonPropertyName("id")] - public string? Id { get; set; } - - /// - /// The locale associated with the element. - /// - [JsonPropertyName("lang")] - public string? Lang { get; set; } - - /// - /// Controls the visibility of the element. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. + /// Defines if a feedback message should be displayed after the action is executed. /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + [JsonPropertyName("hide")] + public bool? Hide { get; set; } /// - /// The URL (or Base64-encoded Data URI) of the image. Acceptable formats are PNG, JPEG, GIF and SVG. + /// Serializes this TeamsSubmitActionFeedback into a JSON string. /// - [JsonPropertyName("url")] - public string? Url { get; set; } + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } - /// - /// The size of the inline image. - /// - [JsonPropertyName("size")] - public SizeEnum? Size { get; set; } + public TeamsSubmitActionFeedback WithKey(string value) + { + this.Key = value; + return this; + } - /// - /// The style of the inline image. - /// - [JsonPropertyName("style")] - public ImageStyle? Style { get; set; } + public TeamsSubmitActionFeedback WithHide(bool value) + { + this.Hide = value; + return this; + } +} +/// +/// Defines how a card can be refreshed by making a request to the target Bot. +/// +public class RefreshDefinition : SerializableObject +{ /// - /// An Action that will be invoked when the image is tapped or clicked. Action.ShowCard is not supported. + /// Deserializes a JSON string into an object of type RefreshDefinition. /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } + public static RefreshDefinition? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } /// - /// A set of theme-specific image URLs. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("themedUrls")] - public IList? ThemedUrls { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The area of a Layout.AreaGrid layout in which an element should be displayed. + /// The Action.Execute action to invoke to refresh the card. /// - [JsonPropertyName("grid.area")] - public string? GridArea { get; set; } + [JsonPropertyName("action")] + public ExecuteAction? Action { get; set; } /// - /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The list of user Ids for which the card will be automatically refreshed. In Teams, in chats or channels with more than 60 users, the card will automatically refresh only for users specified in the userIds list. Other users will have to manually click on a "refresh" button. In contexts with fewer than 60 users, the card will automatically refresh for all users. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("userIds")] + public IList? UserIds { get; set; } /// - /// Serializes this ImageRun into a JSON string. + /// Serializes this RefreshDefinition into a JSON string. /// public string Serialize() { @@ -15842,100 +19393,171 @@ public string Serialize() ); } - public ImageRun WithId(string value) + public RefreshDefinition WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public ImageRun WithLang(string value) + public RefreshDefinition WithAction(ExecuteAction value) { - this.Lang = value; + this.Action = value; return this; } - public ImageRun WithIsVisible(bool value) + public RefreshDefinition WithUserIds(params string[] value) { - this.IsVisible = value; + this.UserIds = new List(value); return this; } - public ImageRun WithIsSortKey(bool value) + public RefreshDefinition WithUserIds(IList value) { - this.IsSortKey = value; + this.UserIds = value; return this; } +} + +/// +/// Defines authentication information associated with a card. For more information, refer to the [Bot Framework OAuthCard type](https://docs.microsoft.com/dotnet/api/microsoft.bot.schema.oauthcard) +/// +public class Authentication : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type Authentication. + /// + public static Authentication? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } + + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The text that can be displayed to the end user when prompting them to authenticate. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// The identifier for registered OAuth connection setting information. + /// + [JsonPropertyName("connectionName")] + public string? ConnectionName { get; set; } + + /// + /// The buttons that should be displayed to the user when prompting for authentication. The array MUST contain one button of type “signin”. Other button types are not currently supported. + /// + [JsonPropertyName("buttons")] + public IList? Buttons { get; set; } - public ImageRun WithUrl(string value) + /// + /// Provides information required to enable on-behalf-of single sign-on user authentication. + /// + [JsonPropertyName("tokenExchangeResource")] + public TokenExchangeResource? TokenExchangeResource { get; set; } + + /// + /// Serializes this Authentication into a JSON string. + /// + public string Serialize() { - this.Url = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public ImageRun WithSize(SizeEnum value) + public Authentication WithKey(string value) { - this.Size = value; + this.Key = value; return this; } - public ImageRun WithStyle(ImageStyle value) + public Authentication WithText(string value) { - this.Style = value; + this.Text = value; return this; } - public ImageRun WithSelectAction(Action value) + public Authentication WithConnectionName(string value) { - this.SelectAction = value; + this.ConnectionName = value; return this; } - public ImageRun WithThemedUrls(params IList value) + public Authentication WithButtons(params AuthCardButton[] value) { - this.ThemedUrls = value; + this.Buttons = new List(value); return this; } - public ImageRun WithGridArea(string value) + public Authentication WithButtons(IList value) { - this.GridArea = value; + this.Buttons = value; return this; } - public ImageRun WithFallback(IUnion value) + public Authentication WithTokenExchangeResource(TokenExchangeResource value) { - this.Fallback = value; + this.TokenExchangeResource = value; return this; } } /// -/// Defines a theme-specific URL. +/// Defines a button as displayed when prompting a user to authenticate. For more information, refer to the [Bot Framework CardAction type](https://docs.microsoft.com/dotnet/api/microsoft.bot.schema.cardaction). /// -public class ThemedUrl : SerializableObject +public class AuthCardButton : SerializableObject { /// - /// Deserializes a JSON string into an object of type ThemedUrl. + /// Deserializes a JSON string into an object of type AuthCardButton. /// - public static ThemedUrl? Deserialize(string json) + public static AuthCardButton? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// The theme this URL applies to. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("theme")] - public ThemeName? Theme { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The URL to use for the associated theme. + /// Must be **signin**. /// - [JsonPropertyName("url")] - public string? Url { get; set; } + [JsonPropertyName("type")] + public string? Type { get; set; } /// - /// Serializes this ThemedUrl into a JSON string. + /// The caption of the button. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// A URL to an image to display alongside the button’s caption. + /// + [JsonPropertyName("image")] + public string? Image { get; set; } + + /// + /// The value associated with the button. The meaning of value depends on the button’s type. + /// + [JsonPropertyName("value")] + public string? Value { get; set; } + + /// + /// Serializes this AuthCardButton into a JSON string. /// public string Serialize() { @@ -15949,195 +19571,226 @@ public string Serialize() ); } - public ThemedUrl WithTheme(ThemeName value) + public AuthCardButton WithKey(string value) { - this.Theme = value; + this.Key = value; return this; } - public ThemedUrl WithUrl(string value) + public AuthCardButton WithType(string value) { - this.Url = value; + this.Type = value; + return this; + } + + public AuthCardButton WithTitle(string value) + { + this.Title = value; + return this; + } + + public AuthCardButton WithImage(string value) + { + this.Image = value; + return this; + } + + public AuthCardButton WithValue(string value) + { + this.Value = value; return this; } } /// -/// A column in a ColumnSet element. +/// Defines information required to enable on-behalf-of single sign-on user authentication. For more information, refer to the [Bot Framework TokenExchangeResource type](https://docs.microsoft.com/dotnet/api/microsoft.bot.schema.tokenexchangeresource) /// -public class Column : CardElement +public class TokenExchangeResource : SerializableObject { /// - /// Deserializes a JSON string into an object of type Column. + /// Deserializes a JSON string into an object of type TokenExchangeResource. /// - public static Column? Deserialize(string json) + public static TokenExchangeResource? Deserialize(string json) { - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); } /// - /// Optional. If specified, must be **Column**. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("type")] - public string Type { get; } = "Column"; + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// A unique identifier for the element or action. Input elements must have an id, otherwise they will not be validated and their values will not be sent to the Bot. + /// The unique identified of this token exchange instance. /// [JsonPropertyName("id")] public string? Id { get; set; } /// - /// A list of capabilities the element requires the host application to support. If the host application doesn't support at least one of the listed capabilities, the element is not rendered (or its fallback is rendered if provided). - /// - [JsonPropertyName("requires")] - public HostCapabilities? Requires { get; set; } - - /// - /// The locale associated with the element. - /// - [JsonPropertyName("lang")] - public string? Lang { get; set; } - - /// - /// Controls the visibility of the element. - /// - [JsonPropertyName("isVisible")] - public bool? IsVisible { get; set; } - - /// - /// Controls whether a separator line should be displayed above the element to visually separate it from the previous element. No separator will be displayed for the first element in a container, even if this property is set to true. + /// An application ID or resource identifier with which to exchange a token on behalf of. This property is identity provider- and application-specific. /// - [JsonPropertyName("separator")] - public bool? Separator { get; set; } + [JsonPropertyName("uri")] + public string? Uri { get; set; } /// - /// The height of the element. When set to stretch, the element will use the remaining vertical space in its container. + /// An identifier for the identity provider with which to attempt a token exchange. /// - [JsonPropertyName("height")] - public ElementHeight? Height { get; set; } + [JsonPropertyName("providerId")] + public string? ProviderId { get; set; } /// - /// Controls how the element should be horizontally aligned. + /// Serializes this TokenExchangeResource into a JSON string. /// - [JsonPropertyName("horizontalAlignment")] - public HorizontalAlignment? HorizontalAlignment { get; set; } + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } - /// - /// Controls the amount of space between this element and the previous one. No space will be added for the first element in a container. - /// - [JsonPropertyName("spacing")] - public Spacing? Spacing { get; set; } + public TokenExchangeResource WithKey(string value) + { + this.Key = value; + return this; + } - /// - /// Controls for which card width the element should be displayed. If targetWidth isn't specified, the element is rendered at all card widths. Using targetWidth makes it possible to author responsive cards that adapt their layout to the available horizontal space. For more details, see [Responsive layout](https://adaptivecards.microsoft.com/?topic=responsive-layout). - /// - [JsonPropertyName("targetWidth")] - public TargetWidth? TargetWidth { get; set; } + public TokenExchangeResource WithId(string value) + { + this.Id = value; + return this; + } - /// - /// Controls whether the element should be used as a sort key by elements that allow sorting across a collection of elements. - /// - [JsonPropertyName("isSortKey")] - public bool? IsSortKey { get; set; } + public TokenExchangeResource WithUri(string value) + { + this.Uri = value; + return this; + } - /// - /// An Action that will be invoked when the element is tapped or clicked. Action.ShowCard is not supported. - /// - [JsonPropertyName("selectAction")] - public Action? SelectAction { get; set; } + public TokenExchangeResource WithProviderId(string value) + { + this.ProviderId = value; + return this; + } +} +/// +/// Represents a set of Teams-specific properties on a card. +/// +public class TeamsCardProperties : SerializableObject +{ /// - /// The style of the container. Container styles control the colors of the background, border and text inside the container, in such a way that contrast requirements are always met. + /// Deserializes a JSON string into an object of type TeamsCardProperties. /// - [JsonPropertyName("style")] - public ContainerStyle? Style { get; set; } + public static TeamsCardProperties? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } /// - /// Controls if a border should be displayed around the container. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("showBorder")] - public bool? ShowBorder { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// Controls if the container should have rounded corners. + /// Controls the width of the card in a Teams chat. + /// + /// Note that setting `width` to "full" will not actually stretch the card to the "full width" of the chat pane. It will only make the card wider than when the `width` property isn't set. /// - [JsonPropertyName("roundedCorners")] - public bool? RoundedCorners { get; set; } + [JsonPropertyName("width")] + public TeamsCardWidth? Width { get; set; } /// - /// The layouts associated with the container. The container can dynamically switch from one layout to another as the card's width changes. See [Container layouts](https://adaptivecards.microsoft.com/?topic=container-layouts) for more details. + /// The Teams-specific entities associated with the card. /// - [JsonPropertyName("layouts")] - public IList? Layouts { get; set; } + [JsonPropertyName("entities")] + public IList? Entities { get; set; } /// - /// Controls if the container should bleed into its parent. A bleeding container extends into its parent's padding. + /// Serializes this TeamsCardProperties into a JSON string. /// - [JsonPropertyName("bleed")] - public bool? Bleed { get; set; } + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); + } - /// - /// The minimum height, in pixels, of the container, in the `px` format. - /// - [JsonPropertyName("minHeight")] - public string? MinHeight { get; set; } + public TeamsCardProperties WithKey(string value) + { + this.Key = value; + return this; + } - /// - /// Defines the container's background image. - /// - [JsonPropertyName("backgroundImage")] - public IUnion? BackgroundImage { get; set; } + public TeamsCardProperties WithWidth(TeamsCardWidth value) + { + this.Width = value; + return this; + } - /// - /// Controls how the container's content should be vertically aligned. - /// - [JsonPropertyName("verticalContentAlignment")] - public VerticalAlignment? VerticalContentAlignment { get; set; } + public TeamsCardProperties WithEntities(params Mention[] value) + { + this.Entities = new List(value); + return this; + } - /// - /// Controls if the content of the card is to be rendered left-to-right or right-to-left. - /// - [JsonPropertyName("rtl")] - public bool? Rtl { get; set; } + public TeamsCardProperties WithEntities(IList value) + { + this.Entities = value; + return this; + } +} +/// +/// Represents a mention to a person. +/// +public class Mention : SerializableObject +{ /// - /// The maximum height, in pixels, of the container, in the `px` format. When the content of a container exceeds the container's maximum height, a vertical scrollbar is displayed. + /// Deserializes a JSON string into an object of type Mention. /// - [JsonPropertyName("maxHeight")] - public string? MaxHeight { get; set; } + public static Mention? Deserialize(string json) + { + return JsonSerializer.Deserialize(json); + } /// - /// The width of the column. If expressed as a number, represents the relative weight of the column in the set. If expressed as a string, `auto` will automatically adjust the column's width according to its content, `stretch` will make the column use the remaining horizontal space (shared with other columns with width set to `stretch`) and using the `px` format will give the column an explicit width in pixels. + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. /// - [JsonPropertyName("width")] - public IUnion? Width { get; set; } + [JsonPropertyName("key")] + public string? Key { get; set; } /// - /// The area of a Layout.AreaGrid layout in which an element should be displayed. + /// Must be **mention**. /// - [JsonPropertyName("grid.area")] - public string? GridArea { get; set; } + [JsonPropertyName("type")] + public string Type { get; } = "mention"; /// - /// An alternate element to render if the type of this one is unsupported or if the host application doesn't support all the capabilities specified in the requires property. + /// The text that will be substituted with the mention. /// - [JsonPropertyName("fallback")] - public IUnion? Fallback { get; set; } + [JsonPropertyName("text")] + public string? Text { get; set; } /// - /// The elements in the column. + /// Defines the entity being mentioned. /// - [JsonPropertyName("items")] - public IList? Items { get; set; } - - public Column(params IList items) - { - this.Items = items; - } + [JsonPropertyName("mentioned")] + public MentionedEntity? Mentioned { get; set; } /// - /// Serializes this Column into a JSON string. + /// Serializes this Mention into a JSON string. /// public string Serialize() { @@ -16151,153 +19804,269 @@ public string Serialize() ); } - public Column WithId(string value) + public Mention WithKey(string value) { - this.Id = value; + this.Key = value; return this; } - public Column WithRequires(HostCapabilities value) + public Mention WithText(string value) { - this.Requires = value; + this.Text = value; return this; } - public Column WithLang(string value) + public Mention WithMentioned(MentionedEntity value) { - this.Lang = value; + this.Mentioned = value; return this; } +} - public Column WithIsVisible(bool value) +/// +/// Represents a mentioned person or tag. +/// +public class MentionedEntity : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type MentionedEntity. + /// + public static MentionedEntity? Deserialize(string json) { - this.IsVisible = value; - return this; + return JsonSerializer.Deserialize(json); } - public Column WithSeparator(bool value) - { - this.Separator = value; - return this; - } + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } - public Column WithHeight(ElementHeight value) - { - this.Height = value; - return this; - } + /// + /// The Id of a person (typically a Microsoft Entra user Id) or tag. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } - public Column WithHorizontalAlignment(HorizontalAlignment value) - { - this.HorizontalAlignment = value; - return this; - } + /// + /// The name of the mentioned entity. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } - public Column WithSpacing(Spacing value) + /// + /// The type of the mentioned entity. + /// + [JsonPropertyName("mentionType")] + public MentionType? MentionType { get; set; } = MentionType.Person; + + /// + /// Serializes this MentionedEntity into a JSON string. + /// + public string Serialize() { - this.Spacing = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public Column WithTargetWidth(TargetWidth value) + public MentionedEntity WithKey(string value) { - this.TargetWidth = value; + this.Key = value; return this; } - public Column WithIsSortKey(bool value) + public MentionedEntity WithId(string value) { - this.IsSortKey = value; + this.Id = value; return this; } - public Column WithSelectAction(Action value) + public MentionedEntity WithName(string value) { - this.SelectAction = value; + this.Name = value; return this; } - public Column WithStyle(ContainerStyle value) + public MentionedEntity WithMentionType(MentionType value) { - this.Style = value; + this.MentionType = value; return this; } +} - public Column WithShowBorder(bool value) +/// +/// Card-level metadata. +/// +public class CardMetadata : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type CardMetadata. + /// + public static CardMetadata? Deserialize(string json) { - this.ShowBorder = value; - return this; + return JsonSerializer.Deserialize(json); } - public Column WithRoundedCorners(bool value) + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The URL the card originates from. When `webUrl` is set, the card is dubbed an **Adaptive Card-based Loop Component** and, when pasted in Teams or other Loop Component-capable host applications, the URL will unfurl to the same exact card. + /// + [JsonPropertyName("webUrl")] + public string? WebUrl { get; set; } + + /// + /// Serializes this CardMetadata into a JSON string. + /// + public string Serialize() { - this.RoundedCorners = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public Column WithLayouts(params IList value) + public CardMetadata WithKey(string value) { - this.Layouts = value; + this.Key = value; return this; } - public Column WithBleed(bool value) + public CardMetadata WithWebUrl(string value) { - this.Bleed = value; + this.WebUrl = value; return this; } +} - public Column WithMinHeight(string value) +/// +/// The resources that can be used in the body of the card. +/// +public class Resources : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type Resources. + /// + public static Resources? Deserialize(string json) { - this.MinHeight = value; - return this; + return JsonSerializer.Deserialize(json); } - public Column WithBackgroundImage(IUnion value) + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// String resources that can provide translations in multiple languages. String resources make it possible to craft cards that are automatically localized according to the language settings of the application that displays the card. + /// + [JsonPropertyName("strings")] + public IDictionary? Strings { get; set; } + + /// + /// Serializes this Resources into a JSON string. + /// + public string Serialize() { - this.BackgroundImage = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public Column WithVerticalContentAlignment(VerticalAlignment value) + public Resources WithKey(string value) { - this.VerticalContentAlignment = value; + this.Key = value; return this; } - public Column WithRtl(bool value) + public Resources WithStrings(IDictionary value) { - this.Rtl = value; + this.Strings = value; return this; } +} - public Column WithMaxHeight(string value) +/// +/// Defines the replacement string values. +/// +public class StringResource : SerializableObject +{ + /// + /// Deserializes a JSON string into an object of type StringResource. + /// + public static StringResource? Deserialize(string json) { - this.MaxHeight = value; - return this; + return JsonSerializer.Deserialize(json); } - public Column WithWidth(IUnion value) + /// + /// Defines an optional key for the object. Keys are seldom needed, but in some scenarios, specifying keys can help maintain visual state in the host application. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The default value of the string, which is used when no matching localized value is found. + /// + [JsonPropertyName("defaultValue")] + public string? DefaultValue { get; set; } + + /// + /// Localized values of the string, where keys represent the locale (e.g. `en-US`) in the `(-)` format. `` is the 2-letter language code and `` is the optional 2-letter country code. + /// + [JsonPropertyName("localizedValues")] + public IDictionary? LocalizedValues { get; set; } + + /// + /// Serializes this StringResource into a JSON string. + /// + public string Serialize() { - this.Width = value; - return this; + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + } + ); } - public Column WithGridArea(string value) + public StringResource WithKey(string value) { - this.GridArea = value; + this.Key = value; return this; } - public Column WithFallback(IUnion value) + public StringResource WithDefaultValue(string value) { - this.Fallback = value; + this.DefaultValue = value; return this; } - public Column WithItems(params IList value) + public StringResource WithLocalizedValues(IDictionary value) { - this.Items = value; + this.LocalizedValues = value; return this; } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Cards/Utilities/OpenDialogData.cs b/Libraries/Microsoft.Teams.Cards/Utilities/OpenDialogData.cs new file mode 100644 index 000000000..958c980e6 --- /dev/null +++ b/Libraries/Microsoft.Teams.Cards/Utilities/OpenDialogData.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Cards; + +/// +/// Convenience class for creating the data payload to open a dialog +/// from an Action.Submit. +/// +/// Abstracts away the msteams: { type: "task/fetch" } protocol detail +/// and sets a reserved dialog_id field for handler routing. +/// +/// +/// +/// new SubmitAction { Data = new Union<string, SubmitActionData>(new OpenDialogData("simple_form")) } +/// +/// +public class OpenDialogData : SubmitActionData +{ + private const string ReservedKeyword = "dialog_id"; + + public OpenDialogData(string dialogId, IDictionary? extraData = null) + { + Msteams = new TaskFetchSubmitActionData(); + if (extraData != null) + { + foreach (var kvp in extraData) + { + NonSchemaProperties[kvp.Key] = kvp.Value; + } + } + NonSchemaProperties[ReservedKeyword] = dialogId; + } +} \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Cards/Utilities/SubmitData.cs b/Libraries/Microsoft.Teams.Cards/Utilities/SubmitData.cs new file mode 100644 index 000000000..8d30f480c --- /dev/null +++ b/Libraries/Microsoft.Teams.Cards/Utilities/SubmitData.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Cards; + +/// +/// Utility class for creating submit data with action-based routing. +/// +/// Extends the generated with a convenience constructor +/// that accepts an action identifier for handler routing. +/// +/// +/// +/// new ExecuteAction { Data = new Union<string, SubmitActionData>(new SubmitData("save_profile", new() { ["entity_id"] = "12345" })) } +/// +/// +public class SubmitData : SubmitActionData +{ + private const string ReservedKeyword = "action"; + + public SubmitData(string action, IDictionary? extraData = null) + { + if (extraData != null) + { + foreach (var kvp in extraData) + { + NonSchemaProperties[kvp.Key] = kvp.Value; + } + } + NonSchemaProperties[ReservedKeyword] = action; + } +} \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index 11a4e5321..d82780b4a 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Api.Auth; @@ -10,21 +10,76 @@ public class TeamsSettings public string? ClientId { get; set; } public string? ClientSecret { get; set; } public string? TenantId { get; set; } + public string? Cloud { get; set; } + + /// Override the Azure AD login endpoint. + public string? LoginEndpoint { get; set; } + + /// Override the default login tenant. + public string? LoginTenant { get; set; } + + /// Override the Bot Framework OAuth scope. + public string? BotScope { get; set; } + + /// Override the Bot Framework token service URL. + public string? TokenServiceUrl { get; set; } + + /// Override the OpenID metadata URL for token validation. + public string? OpenIdMetadataUrl { get; set; } + + /// Override the token issuer for Bot Framework tokens. + public string? TokenIssuer { get; set; } + + /// Override the Microsoft Graph token scope. + public string? GraphScope { get; set; } public bool Empty { get { return ClientId == "" || ClientSecret == ""; } } + /// + /// Resolves the by starting from + /// (or the setting, or ), then applying + /// any per-endpoint overrides from settings. + /// + public CloudEnvironment ResolveCloud(CloudEnvironment? programmaticCloud = null) + { + var baseCloud = programmaticCloud + ?? (!string.IsNullOrWhiteSpace(Cloud) ? CloudEnvironment.FromName(Cloud) : null) + ?? CloudEnvironment.Public; + + return baseCloud.WithOverrides( + loginEndpoint: LoginEndpoint, + loginTenant: LoginTenant, + botScope: BotScope, + tokenServiceUrl: TokenServiceUrl, + openIdMetadataUrl: OpenIdMetadataUrl, + tokenIssuer: TokenIssuer, + graphScope: GraphScope + ); + } + public AppOptions Apply(AppOptions? options = null) { options ??= new AppOptions(); + var cloud = ResolveCloud(options.Cloud); + options.Cloud = cloud; + if (ClientId is not null && ClientSecret is not null && !Empty) { - options.Credentials = new ClientCredentials(ClientId, ClientSecret, TenantId); + var credentials = new ClientCredentials(ClientId, ClientSecret, TenantId) + { + Cloud = cloud + }; + options.Credentials = credentials; + } + else if (options.Credentials is ClientCredentials existingCredentials) + { + existingCredentials.Cloud = cloud; } return options; } -} \ No newline at end of file +} diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph/Microsoft.Teams.Extensions.Graph.csproj b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph/Microsoft.Teams.Extensions.Graph.csproj index 9f3cb237a..a77da666f 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph/Microsoft.Teams.Extensions.Graph.csproj +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph/Microsoft.Teams.Extensions.Graph.csproj @@ -17,6 +17,7 @@ + diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs index 01f28920a..507dfb438 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs @@ -31,6 +31,10 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder var settings = builder.Configuration.GetTeams(); var loggingSettings = builder.Configuration.GetTeamsLogging(); + // cloud environment (base preset + per-endpoint overrides) + var cloud = settings.ResolveCloud(options.Cloud); + options.Cloud = cloud; + // client credentials if (options.Credentials is null && settings.ClientId is not null && settings.ClientSecret is not null && !settings.Empty) { @@ -38,7 +42,12 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder settings.ClientId, settings.ClientSecret, settings.TenantId - ); + ) + { Cloud = cloud }; + } + else if (options.Credentials is ClientCredentials existingCredentials) + { + existingCredentials.Cloud = cloud; } options.Logger ??= new ConsoleLogger(loggingSettings); @@ -56,14 +65,21 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder var settings = builder.Configuration.GetTeams(); var loggingSettings = builder.Configuration.GetTeamsLogging(); + // cloud environment (base preset + per-endpoint overrides) + var cloud = settings.ResolveCloud(); + appBuilder = appBuilder.AddCloud(cloud); + // client credentials if (settings.ClientId is not null && settings.ClientSecret is not null && !settings.Empty) { - appBuilder = appBuilder.AddCredentials(new ClientCredentials( + var credentials = new ClientCredentials( settings.ClientId, settings.ClientSecret, settings.TenantId - )); + ) + { Cloud = cloud }; + + appBuilder = appBuilder.AddCredentials(credentials); } var app = appBuilder.Build(); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs index 70bc08ca3..89cd4dade 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Events; using Microsoft.Teams.Apps.Plugins; @@ -90,6 +91,16 @@ public DevToolsPlugin AddPage(Page page) public Task OnInit(App app, CancellationToken cancellationToken = default) { + var hostEnvironment = _services.GetService(); + var isProduction = hostEnvironment?.IsProduction() + ?? string.Equals(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), "Production", StringComparison.OrdinalIgnoreCase); + if (isProduction) + { + throw new InvalidOperationException( + "Devtools plugin cannot be used in production environments. " + + "Remove the devtools plugin from your app configuration."); + } + foreach (var page in _settings.Pages) { AddPage(page); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs index 4c19075e6..e30c838fb 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs @@ -89,7 +89,9 @@ public void Update(string text) { if (_index == 1 && _queue.Count == 0 && _lock.CurrentCount > 0) return null; if (_result is not null) return _result; - while (_id is null || _queue.Count > 0) + // _lock.CurrentCount == 0 means Flush() is mid-await (queue drained but SendActivity calls + // still pending). Wait it out so the final message doesn't race in-flight chunks. + while (_id is null || _queue.Count > 0 || _lock.CurrentCount == 0) { await Task.Delay(50, cancellationToken).ConfigureAwait(false); } @@ -136,11 +138,10 @@ protected async Task Flush() _timeout = null; } - var i = 0; - Queue informativeUpdates = new(); + var dequeued = 0; - while (i <= 10 && _queue.TryDequeue(out var activity)) + while (_queue.TryDequeue(out var activity)) { if (activity is MessageActivity message) { @@ -161,11 +162,11 @@ protected async Task Flush() informativeUpdates.Enqueue(typing); } - i++; + dequeued++; _count++; } - if (i == 0) return; + if (dequeued == 0) return; // Send informative updates if (informativeUpdates.Count > 0) diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs index 760d94da6..1d814979f 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs @@ -119,8 +119,9 @@ public static class EntraTokenAuthConstants public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostApplicationBuilder builder, bool skipAuth = false) { var settings = builder.Configuration.GetTeams(); + var cloud = settings.ResolveCloud(); - var teamsValidationSettings = new TeamsValidationSettings(); + var teamsValidationSettings = new TeamsValidationSettings(cloud); if (!string.IsNullOrEmpty(settings.ClientId)) { teamsValidationSettings.AddDefaultAudiences(settings.ClientId); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs index 5443c9281..324346be3 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs @@ -1,11 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Auth; + namespace Microsoft.Teams.Plugins.AspNetCore.Extensions; public class TeamsValidationSettings { - public string OpenIdMetadataUrl = "https://login.botframework.com/v1/.well-known/openidconfiguration"; + public string OpenIdMetadataUrl; public List Audiences = []; - public List Issuers = [ - "https://api.botframework.com", + public List Issuers; + public string LoginEndpoint; + + public TeamsValidationSettings() : this(CloudEnvironment.Public) + { + } + + public TeamsValidationSettings(CloudEnvironment cloud) + { + LoginEndpoint = cloud.LoginEndpoint; + OpenIdMetadataUrl = cloud.OpenIdMetadataUrl; + Issuers = [ + cloud.TokenIssuer, "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", // Emulator Auth v3.1, 1.0 token "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", // Emulator Auth v3.1, 2.0 token "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", // Emulator Auth v3.2, 1.0 token @@ -13,6 +29,7 @@ public class TeamsValidationSettings "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", // Copilot Auth v1.0 token "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", // Copilot Auth v2.0 token ]; + } public void AddDefaultAudiences(string ClientId) { @@ -29,13 +46,21 @@ public IEnumerable GetValidIssuersForTenant(string? tenantId) var validIssuers = new List(); if (!string.IsNullOrEmpty(tenantId)) { - validIssuers.Add($"https://login.microsoftonline.com/{tenantId}/"); + validIssuers.Add($"{LoginEndpoint}/{tenantId}/"); + } + else + { + // When no tenant ID is configured, issuer validation will be skipped. + // This accepts tokens from any Azure AD tenant. + System.Diagnostics.Trace.TraceWarning( + "No tenant ID provided for Entra token validation. " + + "Issuer validation will be skipped, accepting tokens from any tenant."); } return validIssuers; } public string GetTenantSpecificOpenIdMetadataUrl(string? tenantId) { - return $"https://login.microsoftonline.com/{tenantId ?? "common"}/v2.0/.well-known/openid-configuration"; + return $"{LoginEndpoint}/{tenantId ?? "common"}/v2.0/.well-known/openid-configuration"; } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/version.json b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/version.json index 2d95ee2d5..6f241820d 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/version.json +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.0.6-preview.{height}", + "version": "2.0.7-preview.{height}", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$", diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/version.json b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/version.json index 2d95ee2d5..6f241820d 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/version.json +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.0.6-preview.{height}", + "version": "2.0.7-preview.{height}", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$", diff --git a/README.md b/README.md index 0cdafc1dd..c9579d597 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ a suite of packages used to build on the Teams Platform. [![📖 Getting Started](https://img.shields.io/badge/📖%20Getting%20Started-blue?style=for-the-badge)](https://microsoft.github.io/teams-sdk) +## Questions & Issues + +- **Questions or Feature Requests**: Please use [GitHub Discussions](https://github.com/microsoft/teams-sdk/discussions) +- **Bug Reports**: Please [open an issue](https://github.com/microsoft/teams.net/issues/new/choose) + ### Build ```bash @@ -68,6 +73,3 @@ $: dotnet test - [`Microsoft.Teams.Plugins.External.Mcp`](./Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/README.md) - [`Microsoft.Teams.Plugins.External.McpClient`](./Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/README.md) -## Feedback and Support - -For questions, feedback, or support regarding the Teams SDK, please contact us at [TeamsAISDKFeedback@microsoft.com](mailto:TeamsAISDKFeedback@microsoft.com). diff --git a/RELEASE.md b/RELEASE.md index e463fd242..55072d864 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -6,9 +6,8 @@ This document describes how to release packages for the Teams SDK for .NET. It a | Pipeline | File | Trigger | Signing | Destination | Approval | |----------|------|---------|---------|-------------|----------| -| **teams.net-pr** | ci.yaml | PR to `main`/`release/*` | No | Pipeline artifacts only | None | -| **teams.net-preview** | publish-preview.yaml | Manual (`Internal`/`Public`) | Public only | Internal feed or nuget.org | Public only | -| **teams.net** | publish.yml | Manual | Yes | nuget.org | Required | +| **Teams.NET-PR** | ci.yaml | PR to `main`/`release/*` | No | Pipeline artifacts only | None | +| **Teams.NET-ESRP** | publish.yaml | Manual (`Internal`/`Public`) | Public only | Internal feed or nuget.org | Public only | | **BotCore-CD** | cd-core.yaml | PR/push to `next/*` (`core/**`) | No | Internal feed (`next/core` branch) | Auto | Note: Public packages are available on nuget.org. Internal feed packages are for Microsoft internal use. @@ -38,24 +37,33 @@ Versions are managed by **Nerdbank.GitVersioning** via [version.json](version.js ### Producing a Stable Release Version -To produce a non-preview release (e.g., `0.0.0`, without suffix), you must **edit version.json before running publish.yml**: +To produce a non-preview release (e.g., `2.0.7`), you must work from the `releases/v2` branch: -1. Create a PR to change version.json: +1. Merge `main` into `releases/v2`: + ```bash + git checkout releases/v2 + git merge main + ``` +2. Edit `version.json` to remove the preview suffix: ```json { - "version": "0.0.0" + "version": "2.0.7" } ``` -2. Merge the PR -3. Run the **teams.net** (`publish.yml`) pipeline manually -4. Approve the push to nuget.org -5. Create another PR to bump the version for the next preview cycle: +3. Commit and push the version change to `releases/v2` +4. Run the **Teams.NET-ESRP** (`publish.yaml`) pipeline manually from the `releases/v2` branch with **Public** publish type +5. Approve the push to nuget.org +6. After the release, bump the version for the next cycle: ```json { - "version": "0.0.0+1-preview.{height}" + "version": "2.0.8-preview.{height}" } ``` +### Producing Preview Releases + +Preview releases can be published directly from the `main` branch since `version.json` on `main` includes the preview suffix. + ### Note on publicReleaseRefSpec The `publicReleaseRefSpec` in version.json controls metadata (e.g., whether a build is considered "public" for telemetry), but it does **not** affect the version number itself. The version string is determined entirely by the `"version"` field. @@ -69,64 +77,67 @@ The `teams-net-publish` environment in Azure DevOps controls who can approve rel 3. Click **Approvals and checks** 4. Add/remove approvers as needed -## Publishing Preview Packages (publish-preview pipeline) +## Publishing Packages (Teams.NET-ESRP pipeline) + +The `Teams.NET-ESRP` pipeline is triggered manually and requires selecting a **Publish Type**: `Internal` or `Public`. The version of the packages is determined by Nerdbank.GitVersioning from `version.json`, so the same pipeline can publish both preview and stable releases. -The `publish-preview` pipeline is triggered manually and requires selecting a **Publish Type**: `Internal` or `Public`. +**Branch Strategy:** +- **Preview releases**: Publish from `main` branch (version.json contains preview suffix) +- **Stable releases**: Publish from `releases/v2` branch (version.json has no suffix) -### Internal Previews +### Internal Packages -Pushes unsigned packages to the internal ADO `TeamsSDKPreviews` feed. +Pushes unsigned packages to the internal ADO `TeamsSDKPreviews` feed (useful for testing before public release). -1. Go to **Pipelines** > **publish-preview** +1. Go to **Pipelines** > **Teams.NET-ESRP** 2. Click **Run pipeline** -3. Select the branch to build from +3. Select the branch to build from (`main` for previews, `releases/v2` for stable) 4. Choose **Internal** as the Publish Type 5. Pipeline runs: Build > Test > Pack > Push to internal feed No approval is required. Packages are available immediately in the internal feed. -### Public Previews +### Public Packages -Signs packages (Authenticode + NuGet) and pushes to nuget.org. +Signs packages (Authenticode + NuGet) and pushes to nuget.org. The package version (preview or stable) is determined by `version.json` on the selected branch. -1. Go to **Pipelines** > **publish-preview** +1. Go to **Pipelines** > **Teams.NET-ESRP** 2. Click **Run pipeline** -3. Select the branch to build from +3. Select the branch to build from: + - `main` for preview releases + - `releases/v2` for stable releases 4. Choose **Public** as the Publish Type 5. Pipeline runs: Build > Test > Sign > Pack 6. **PushToNuGet stage** waits for approval 7. Approver reviews in ADO and clicks **Approve** 8. Packages are pushed to nuget.org -#### Installing Published Preview Packages +#### Installing Published Packages -Preview packages, once published, work identically to stable releases and are available on the same profile: +Once published, packages are available on the [teams-sdk nuget.org profile](https://www.nuget.org/profiles/teams-sdk). +For stable releases: ```bash -dotnet add package Microsoft.Teams.Apps --version 0.0.0-preview.N +dotnet add package Microsoft.Teams.Apps --version 0.0.0 ``` -Available preview versions can be found on the [teams-sdk nuget.org profile](https://www.nuget.org/profiles/teams-sdk) or by using: - +For preview releases: ```bash -dotnet package search Microsoft.Teams.Apps --prerelease +dotnet add package Microsoft.Teams.Apps --version 0.0.0-preview.N ``` -## Production Releases (teams.net pipeline) - -Production releases are triggered manually via `publish.yml`. +You can search for available versions using: +```bash +# Stable only +dotnet package search Microsoft.Teams.Apps -1. Go to **Pipelines** > **teams.net** -2. Click **Run pipeline** -3. Select the branch/tag to release -4. Pipeline runs: Build > Test > Sign > Pack -5. **PushToNuGet stage** waits for approval -6. Approver reviews in ADO and clicks **Approve** -7. Packages are pushed to nuget.org +# Include prereleases +dotnet package search Microsoft.Teams.Apps --prerelease +``` -## CI Validation (teams.net-pr pipeline) +## CI Validation (Teams.NET-PR pipeline) -The `teams.net-pr` pipeline runs automatically on PRs targeting `main` or `release/*` branches (excluding `core/**` paths). It does not publish packages. +The `Teams.NET-PR` pipeline runs automatically on PRs targeting `main` or `release/*` branches (excluding `core/**` paths). It does not publish packages. 1. Open or update a PR targeting `main` or `release/*` 2. Pipeline runs: Build > Test > Pack diff --git a/Samples/Samples.AI/Handlers/FeedbackHandler.cs b/Samples/Samples.AI/Handlers/FeedbackHandler.cs index 572c1ffb5..8e41c5b7e 100644 --- a/Samples/Samples.AI/Handlers/FeedbackHandler.cs +++ b/Samples/Samples.AI/Handlers/FeedbackHandler.cs @@ -3,8 +3,12 @@ using Microsoft.Teams.AI.Models.OpenAI; using Microsoft.Teams.AI.Prompts; using Microsoft.Teams.AI.Templates; +using Microsoft.Teams.Api; using Microsoft.Teams.Api.Activities.Invokes; using Microsoft.Teams.Apps; +using Microsoft.Teams.Cards; + +using TaskModules = Microsoft.Teams.Api.TaskModules; namespace Samples.AI.Handlers; @@ -38,13 +42,15 @@ public static async Task HandleFeedbackLoop(OpenAIChatModel model, IContext + /// Builds the task module (dialog) shown when the user clicks a feedback + /// button on a message whose feedback loop type is custom. + /// + public static TaskModules.Response HandleFeedbackFetchTask(IContext context) + { + var reaction = context.Activity.Value.Data.ActionValue.Reaction; + context.Log.Info($"[HANDLER] Feedback fetch-task invoked, reaction: {reaction}"); + + var card = new AdaptiveCard + { + Schema = "http://adaptivecards.io/schemas/adaptive-card.json", + Body = new List + { + new TextBlock($"You reacted {reaction}. Tell us more (optional):") { Wrap = true }, + new TextInput + { + Id = "feedbackText", + Placeholder = "Your feedback...", + IsMultiline = true, + } + }, + Actions = new List + { + new SubmitAction { Title = "Submit" } + } + }; + + return new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo + { + Title = "Feedback", + Card = new Attachment(card), + })); + } + /// /// Handles feedback submissions from users /// diff --git a/Samples/Samples.AI/Program.cs b/Samples/Samples.AI/Program.cs index 868cadd49..6134fb1e8 100644 --- a/Samples/Samples.AI/Program.cs +++ b/Samples/Samples.AI/Program.cs @@ -144,7 +144,16 @@ } }); -// Feedback submission handler +// Custom feedback fetch-task handler. +// Teams fires this when the user clicks a feedback button on a message whose +// feedback loop type is 'custom' — the bot must return a task module dialog. +teamsApp.OnMessageFetchTask((context, cancellationToken) => +{ + context.Log.Info($"[HANDLER] Feedback fetch-task received"); + return Task.FromResult(FeedbackHandler.HandleFeedbackFetchTask(context)); +}); + +// Feedback submission handler (fires after the user submits the dialog above). teamsApp.OnFeedback((context, cancellationToken) => { context.Log.Info($"[HANDLER] Feedback submission received"); diff --git a/Samples/Samples.AI/Samples.AI.csproj b/Samples/Samples.AI/Samples.AI.csproj index a22dfe9c7..a2dab774d 100644 --- a/Samples/Samples.AI/Samples.AI.csproj +++ b/Samples/Samples.AI/Samples.AI.csproj @@ -9,6 +9,7 @@ + diff --git a/Samples/Samples.Cards/Program.cs b/Samples/Samples.Cards/Program.cs index 7c789dbaf..1e8a652ba 100644 --- a/Samples/Samples.Cards/Program.cs +++ b/Samples/Samples.Cards/Program.cs @@ -184,7 +184,7 @@ static Microsoft.Teams.Cards.AdaptiveCard CreateBasicAdaptiveCard() new ExecuteAction { Title = "Submit", - Data = new Union(new SubmitActionData { NonSchemaProperties = new Dictionary { { "action", "submit_basic" } } }), + Data = new Union(new SubmitData("submit_basic")), AssociatedInputs = AssociatedInputs.Auto } } @@ -208,7 +208,7 @@ static Microsoft.Teams.Cards.AdaptiveCard CreateProfileCard() new ExecuteAction { Title = "Save", - Data = new Union(new SubmitActionData { NonSchemaProperties = new Dictionary { { "action", "save_profile" }, { "entity_id", "12345" } } }), + Data = new Union(new SubmitData("save_profile", new Dictionary { ["entity_id"] = "12345" })), AssociatedInputs = AssociatedInputs.Auto }, new OpenUrlAction("https://adaptivecards.microsoft.com") { Title = "Learn More" } @@ -233,7 +233,7 @@ static Microsoft.Teams.Cards.AdaptiveCard CreateProfileCardWithValidation() new ExecuteAction { Title = "Save", - Data = new Union(new SubmitActionData { NonSchemaProperties = new Dictionary { { "action", "save_profile" } } }), + Data = new Union(new SubmitData("save_profile")), AssociatedInputs = AssociatedInputs.Auto } } @@ -255,7 +255,7 @@ static Microsoft.Teams.Cards.AdaptiveCard CreateFeedbackCard() new ExecuteAction { Title = "Submit Feedback", - Data = new Union(new SubmitActionData { NonSchemaProperties = new Dictionary { { "action", "submit_feedback" } } }), + Data = new Union(new SubmitData("submit_feedback")), AssociatedInputs = AssociatedInputs.Auto } } @@ -291,7 +291,7 @@ static Microsoft.Teams.Cards.AdaptiveCard CreateTaskFormCard() new ExecuteAction { Title = "Create Task", - Data = new Union(new SubmitActionData { NonSchemaProperties = new Dictionary { { "action", "create_task" } } }), + Data = new Union(new SubmitData("create_task")), AssociatedInputs = AssociatedInputs.Auto, Style = ActionStyle.Positive } diff --git a/Samples/Samples.Dialogs/Program.cs b/Samples/Samples.Dialogs/Program.cs index d78603af5..1f2f8496b 100644 --- a/Samples/Samples.Dialogs/Program.cs +++ b/Samples/Samples.Dialogs/Program.cs @@ -45,7 +45,7 @@ return Task.FromResult(new Microsoft.Teams.Api.TaskModules.Response(new Microsoft.Teams.Api.TaskModules.MessageTask("No data found in the activity value"))); } - var dialogType = data.Value.TryGetProperty("opendialogtype", out var dialogTypeElement) && dialogTypeElement.ValueKind == JsonValueKind.String + var dialogType = data.Value.TryGetProperty("dialog_id", out var dialogTypeElement) && dialogTypeElement.ValueKind == JsonValueKind.String ? dialogTypeElement.GetString() : null; @@ -151,6 +151,16 @@ static string SanitizeForLog(string? input) return input.Replace("\r", "").Replace("\n", ""); } +static Microsoft.Teams.Cards.SubmitAction CreateTaskFetchSubmitAction(string title, string dialogId) +{ + return new Microsoft.Teams.Cards.SubmitAction + { + Title = title, + Data = new Microsoft.Teams.Common.Union( + new Microsoft.Teams.Cards.OpenDialogData(dialogId)) + }; +} + static Microsoft.Teams.Cards.AdaptiveCard CreateDialogLauncherCard() { var card = new Microsoft.Teams.Cards.AdaptiveCard @@ -165,26 +175,10 @@ static Microsoft.Teams.Cards.AdaptiveCard CreateDialogLauncherCard() }, Actions = new List { - new Microsoft.Teams.Cards.TaskFetchAction( - Microsoft.Teams.Cards.TaskFetchAction.FromObject(new { opendialogtype = "simple_form" })) - { - Title = "Simple form test" - }, - new Microsoft.Teams.Cards.TaskFetchAction( - Microsoft.Teams.Cards.TaskFetchAction.FromObject(new { opendialogtype = "webpage_dialog" })) - { - Title = "Webpage Dialog" - }, - new Microsoft.Teams.Cards.TaskFetchAction( - Microsoft.Teams.Cards.TaskFetchAction.FromObject(new { opendialogtype = "multi_step_form" })) - { - Title = "Multi-step Form" - }, - new Microsoft.Teams.Cards.TaskFetchAction( - Microsoft.Teams.Cards.TaskFetchAction.FromObject(new { opendialogtype = "mixed_example" })) - { - Title = "Mixed Example" - } + CreateTaskFetchSubmitAction("Simple form test", "simple_form"), + CreateTaskFetchSubmitAction("Webpage Dialog", "webpage_dialog"), + CreateTaskFetchSubmitAction("Multi-step Form", "multi_step_form"), + CreateTaskFetchSubmitAction("Mixed Example", "mixed_example") } }; diff --git a/Samples/Samples.Quoting/Program.cs b/Samples/Samples.Quoting/Program.cs new file mode 100644 index 000000000..259e455fe --- /dev/null +++ b/Samples/Samples.Quoting/Program.cs @@ -0,0 +1,135 @@ +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Entities; +using Microsoft.Teams.Apps.Activities; +using Microsoft.Teams.Apps.Extensions; +using Microsoft.Teams.Plugins.AspNetCore.DevTools.Extensions; +using Microsoft.Teams.Plugins.AspNetCore.Extensions; + +var builder = WebApplication.CreateBuilder(args); +builder.AddTeams().AddTeamsDevTools(); +var app = builder.Build(); +var teams = app.UseTeams(); + +teams.OnActivity(async (context, cancellationToken) => +{ + context.Log.Info($"[ACTIVITY] Type: {context.Activity.Type}, From: {context.Activity.From?.Name ?? "unknown"}"); + await context.Next(); +}); + +teams.OnMessage(async (context, cancellationToken) => +{ + var activity = context.Activity; + var text = activity.Text?.ToLowerInvariant() ?? ""; + + context.Log.Info($"[MESSAGE] Received: {text}"); + + // ============================================ + // Read inbound quoted replies + // ============================================ + var quotes = activity.GetQuotedMessages(); + if (quotes.Count > 0) + { + var quote = quotes[0].QuotedReply!; + var info = $"Quoted message ID: {quote.MessageId}"; + if (quote.SenderName != null) info += $"\nFrom: {quote.SenderName}"; + if (quote.Preview != null) info += $"\nPreview: \"{quote.Preview}\""; + if (quote.IsReplyDeleted == true) info += "\n(deleted)"; + if (quote.ValidatedMessageReference == true) info += "\n(validated)"; + + await context.Send($"You sent a message with a quoted reply:\n\n{info}", cancellationToken); + } + + // ============================================ + // Reply() — auto-quotes the inbound message + // ============================================ + if (text.Contains("test reply")) + { + await context.Reply("Thanks for your message! This reply auto-quotes it using Reply().", cancellationToken); + return; + } + + // ============================================ + // Quote() — quote a previously sent message by ID + // ============================================ + if (text.Contains("test quote")) + { + var sent = await context.Send("The meeting has been moved to 3 PM tomorrow.", cancellationToken); + await context.Quote(sent.Id, "Just to confirm — does the new time work for everyone?", cancellationToken); + return; + } + + // ============================================ + // AddQuote() — builder with response + // ============================================ + if (text.Contains("test add")) + { + var sent = await context.Send("Please review the latest PR before end of day.", cancellationToken); + var msg = new MessageActivity() + .AddQuote(sent.Id, "Done! Left my comments on the PR."); + await context.Send(msg, cancellationToken); + return; + } + + // ============================================ + // Multi-quote with mixed responses + // ============================================ + if (text.Contains("test multi")) + { + var sentA = await context.Send("We need to update the API docs before launch.", cancellationToken); + var sentB = await context.Send("The design mockups are ready for review.", cancellationToken); + var sentC = await context.Send("CI pipeline is green on main.", cancellationToken); + var msg = new MessageActivity() + .AddQuote(sentA.Id, "I can take the docs — will have a draft by Thursday.") + .AddQuote(sentB.Id, "Looks great, approved!") + .AddQuote(sentC.Id); + await context.Send(msg, cancellationToken); + return; + } + + // ============================================ + // AddQuote() + AddText() — manual control + // ============================================ + if (text.Contains("test manual")) + { + var sent = await context.Send("Deployment to staging is complete.", cancellationToken); + var msg = new MessageActivity() + .AddQuote(sent.Id) + .AddText(" Verified — all smoke tests passing."); + await context.Send(msg, cancellationToken); + return; + } + + // ============================================ + // ToQuoteReply() — obsolete method (temporary) + // ============================================ + if (text.Contains("test obsolete")) + { +#pragma warning disable CS0618 // Obsolete + var placeholder = activity.ToQuoteReply(); +#pragma warning restore CS0618 + await context.Send($"ToQuoteReply() returned: {placeholder}", cancellationToken); + return; + } + + // ============================================ + // Help / Default + // ============================================ + if (text.Contains("help")) + { + await context.Send( + "**Quoting Test Bot**\n\n" + + "**Commands:**\n" + + "- `test reply` - Reply() auto-quotes your message\n" + + "- `test quote` - Quote() quotes a previously sent message\n" + + "- `test add` - AddQuote() builder with response\n" + + "- `test multi` - Multi-quote with mixed responses (one bare quote with no response)\n" + + "- `test manual` - AddQuote() + AddText() manual control\n" + + "- `test obsolete` - ToQuoteReply() obsolete method\n\n" + + "Quote any message to me to see the parsed metadata!", cancellationToken); + return; + } + + await context.Send($"You said: '{activity.Text}'\n\nType `help` to see available commands.", cancellationToken); +}); + +app.Run(); diff --git a/Samples/Samples.Quoting/Properties/launchSettings.TEMPLATE.json b/Samples/Samples.Quoting/Properties/launchSettings.TEMPLATE.json new file mode 100644 index 000000000..3ec8a47b3 --- /dev/null +++ b/Samples/Samples.Quoting/Properties/launchSettings.TEMPLATE.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "Teams__TenantId": "", + "Teams__ClientId": "", + "Teams__ClientSecret": "" + } + } + } +} diff --git a/Samples/Samples.Quoting/README.md b/Samples/Samples.Quoting/README.md new file mode 100644 index 000000000..a8d560b91 --- /dev/null +++ b/Samples/Samples.Quoting/README.md @@ -0,0 +1,100 @@ +# Example: Quoting + +A bot that demonstrates various ways to quote previous messages in Microsoft Teams. + +## Commands + +| Command | Description | +|---------|-------------| +| `test reply` | `Reply()` — auto-quotes the inbound message | +| `test quote` | `Quote()` — sends a message, then quotes it by ID | +| `test add` | `AddQuote()` — sends a message, then quotes it with builder + response | +| `test multi` | Sends two messages, then quotes both with interleaved responses | +| `test manual` | `AddQuote()` + `AddText()` — manual control | +| `test obsolete` | `ToQuoteReply()` — deprecated method (temporary) | +| `help` | Shows available commands | +| *(quote a message)* | Bot reads and displays the quoted reply metadata | + +## Running the Sample + +1. Create and start a dev tunnel: + ```bash + devtunnel user login + devtunnel create quoted-replies --allow-anonymous + devtunnel port create quoted-replies -p 3978 + devtunnel host quoted-replies + ``` + +2. Configure your bot in Azure Portal: + - Set Messaging Endpoint to: `https:///api/messages` + +3. Update `appsettings.json`: + ```json + { + "Teams": { + "TenantId": "your-tenant-id", + "ClientId": "your-bot-app-id", + "ClientSecret": "your-bot-client-secret" + } + } + ``` + +4. Run the sample: + ```bash + cd Samples/Samples.Quoting + dotnet run + ``` + +5. In Teams, quote any message to the bot or use the commands above. + +## Code Highlights + +### Reading Inbound Quoted Replies + +When a user quotes a message and sends it to the bot: + +```csharp +var quotes = activity.GetQuotedMessages(); +if (quotes.Count > 0) +{ + var quote = quotes[0].QuotedReply!; + // quote.MessageId, quote.SenderName, quote.Preview, etc. +} +``` + +### Reply() — Auto-Quotes the Inbound Message + +```csharp +await context.Reply("Got it!", cancellationToken); +``` + +### Quote() — Quote a Specific Message by ID + +```csharp +var sent = await context.Send("This message will be quoted next...", cancellationToken); +await context.Quote(sent.Id, "This quotes the message above", cancellationToken); +``` + +### AddQuote() — Builder for Proactive / Multi-Quote Scenarios + +```csharp +// Single quote with response below it +var sent = await context.Send("This message will be quoted next...", cancellationToken); +var msg = new MessageActivity() + .AddQuote(sent.Id, "Here is my response"); +await context.Send(msg, cancellationToken); + +// Multiple quotes with interleaved responses +var sentA = await context.Send("Message A — will be quoted", cancellationToken); +var sentB = await context.Send("Message B — will be quoted", cancellationToken); +var msg = new MessageActivity() + .AddQuote(sentA.Id, "Response to A") + .AddQuote(sentB.Id, "Response to B"); +await context.Send(msg, cancellationToken); + +// Grouped quotes — omit response to group them +var msg = new MessageActivity("see below for previous messages") + .AddQuote("msg-1") + .AddQuote("msg-2", "Response to both"); +await context.Send(msg, cancellationToken); +``` diff --git a/Samples/Samples.Quoting/Samples.Quoting.csproj b/Samples/Samples.Quoting/Samples.Quoting.csproj new file mode 100644 index 000000000..b2041012a --- /dev/null +++ b/Samples/Samples.Quoting/Samples.Quoting.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + $(NoWarn);ExperimentalTeamsQuotedReplies + + + + + + + + + + + + + + + + + + diff --git a/Samples/Samples.Quoting/appsettings.json b/Samples/Samples.Quoting/appsettings.json new file mode 100644 index 000000000..392635fa0 --- /dev/null +++ b/Samples/Samples.Quoting/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Microsoft.Teams": { + "Enable": "*", + "Level": "debug" + } + }, + "Teams": { + "TenantId": "", + "ClientId": "", + "ClientSecret": "" + }, + "AllowedHosts": "*" +} diff --git a/Samples/Samples.Tab/Web/package-lock.json b/Samples/Samples.Tab/Web/package-lock.json index 88bddd0f9..664c11da1 100644 --- a/Samples/Samples.Tab/Web/package-lock.json +++ b/Samples/Samples.Tab/Web/package-lock.json @@ -7,10 +7,10 @@ "name": "tab", "license": "MIT", "dependencies": { - "@microsoft/teams.client": "2.0.0-preview.11", - "@microsoft/teams.common": "2.0.0-preview.11", - "@microsoft/teams.graph": "2.0.0-preview.11", - "@microsoft/teams.graph-endpoints": "2.0.0-preview.11", + "@microsoft/teams.client": "2.0.9", + "@microsoft/teams.common": "2.0.9", + "@microsoft/teams.graph": "2.0.9", + "@microsoft/teams.graph-endpoints": "2.0.9", "react": "^19.2.1", "react-dom": "^19.2.1" }, @@ -18,7 +18,7 @@ "@vitejs/plugin-react": "^4.3.4", "rimraf": "^6.0.1", "typescript": "^5.4.5", - "vite": "^6.4.1" + "vite": "^6.4.2" } }, "node_modules/@ampproject/remapping": { @@ -872,81 +872,76 @@ } }, "node_modules/@microsoft/teams.api": { - "version": "2.0.0-preview.11", - "resolved": "https://registry.npmjs.org/@microsoft/teams.api/-/teams.api-2.0.0-preview.11.tgz", - "integrity": "sha512-SdYWu58v6ArsLBbLV/NfQhpS8RlIeVDwtXA8I218GFTdP0IPDNxljwzRV8yhSTAkEhLEfINMuh86VmQuXcEjqA==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@microsoft/teams.api/-/teams.api-2.0.9.tgz", + "integrity": "sha512-U8Bv7Ok/zZa4FdwS6xbB2Wts2gYyC3+f2gFPm9chMkYa7O2doFw+7AZXSiUEBY2p5IlvD4RwKEoaXuBeuDqwfQ==", "license": "MIT", - "peer": true, "dependencies": { + "@microsoft/teams.cards": "2.0.9", + "@microsoft/teams.common": "2.0.9", "jwt-decode": "^4.0.0", - "qs": "^6.13.0" + "qs": "^6.14.2" }, "engines": { "node": ">=20" - }, - "peerDependencies": { - "@microsoft/teams.cards": "2.0.0-preview.11", - "@microsoft/teams.common": "2.0.0-preview.11" } }, "node_modules/@microsoft/teams.cards": { - "version": "2.0.0-preview.11", - "resolved": "https://registry.npmjs.org/@microsoft/teams.cards/-/teams.cards-2.0.0-preview.11.tgz", - "integrity": "sha512-ZAFyH7xKi+gP+ZGtADPyU1tDEkLA9QVagzzUsa5wd3clHate/aHC/tR39+X7Z+zmoFzZ82XVHVGfl2HSDQGGxw==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@microsoft/teams.cards/-/teams.cards-2.0.9.tgz", + "integrity": "sha512-YYec0ATVI3jG98UMReTUsT+y8BfoB7JF3kTDzX8Co/iJOV9GGQ86jT+hgYUBK3Vc+avvoimWT8poJu7Clwz2Sg==", "license": "MIT", - "peer": true, "engines": { "node": ">=20" } }, "node_modules/@microsoft/teams.client": { - "version": "2.0.0-preview.11", - "resolved": "https://registry.npmjs.org/@microsoft/teams.client/-/teams.client-2.0.0-preview.11.tgz", - "integrity": "sha512-hsvopeYs5KMLuZWdy9zdQ58LA72YNy+/F6sgjm7oT5/TeZMldldUKZyruDNw5ApVxbtTFZp2hRt88JW9+loCJA==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@microsoft/teams.client/-/teams.client-2.0.9.tgz", + "integrity": "sha512-S3vfLtNytjpBkOAZOSxJg5NQ3AjWYNjoiFhREJyspH+LrmnaCP0X6iJj3MLcmz9g3hlrDBHGa1CrxbIoV56lQQ==", "license": "MIT", "dependencies": { "@azure/msal-browser": "^4.9.1", - "uuid": "^11.0.5" + "@microsoft/teams.api": "2.0.9", + "@microsoft/teams.common": "2.0.9", + "@microsoft/teams.graph": "2.0.9" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@microsoft/teams-js": "^2.35.0", - "@microsoft/teams.api": "2.0.0-preview.11", - "@microsoft/teams.common": "2.0.0-preview.11", - "@microsoft/teams.graph": "2.0.0-preview.11" + "@microsoft/teams-js": "^2.35.0" } }, "node_modules/@microsoft/teams.common": { - "version": "2.0.0-preview.11", - "resolved": "https://registry.npmjs.org/@microsoft/teams.common/-/teams.common-2.0.0-preview.11.tgz", - "integrity": "sha512-bnT4Y9IT54EjJas/VUhMIcdczpNRM9UJc0rPW42qBbpioNeB1eJ3nqo/ydi25LAlKWuEFHQxgFgHfPS9ctiskw==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@microsoft/teams.common/-/teams.common-2.0.9.tgz", + "integrity": "sha512-vgMgZv9uc1v4f3gUlY/+6tjm+0vWMO8Nxrw9pCCvR7Y4+au075vBTDq0mPiq4uT/S1786hEegdny2c+3U1ECCA==", "license": "MIT", "dependencies": { - "axios": "^1.8.2" + "axios": "^1.15.2" }, "engines": { "node": ">=20" } }, "node_modules/@microsoft/teams.graph": { - "version": "2.0.0-preview.11", - "resolved": "https://registry.npmjs.org/@microsoft/teams.graph/-/teams.graph-2.0.0-preview.11.tgz", - "integrity": "sha512-FCyjQY/igcWxYMQb4NvHvvUsJ2W5l3T4dwQlZiNB5Ka6C7tdqvJQUL9SQStJROYLYCMhHR59xZZcyqkNeJb9jQ==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@microsoft/teams.graph/-/teams.graph-2.0.9.tgz", + "integrity": "sha512-AZDAfiGdAzA1cNh84z5p7kSqGHakOPgkoNd1sWM7Hg09jzmwh1F5TuMXLS0CKZjOOBtovXw84cqL5Pdmprq6yA==", "license": "MIT", "dependencies": { - "@microsoft/teams.common": "2.0.0-preview.11", - "qs": "^6.13.0" + "@microsoft/teams.common": "2.0.9", + "qs": "^6.14.2" }, "engines": { "node": ">=20" } }, "node_modules/@microsoft/teams.graph-endpoints": { - "version": "2.0.0-preview.11", - "resolved": "https://registry.npmjs.org/@microsoft/teams.graph-endpoints/-/teams.graph-endpoints-2.0.0-preview.11.tgz", - "integrity": "sha512-AlQgrn8r5mtbdszXvsMPTPzQm/yFZ4ms4R9XXcFG5n1qBP2Q9uLXGeuGswbsKleQ/UHHpw9r9Xjy3amJZ6uGRw==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@microsoft/teams.graph-endpoints/-/teams.graph-endpoints-2.0.9.tgz", + "integrity": "sha512-SpV6p8J3BiUnzOKHfHQS6K/p1L56mJBb5MHVpK40qZe9zUooAkr3u1oZEbWm4dASIeJwEC74GIF+pvp/yZ6mxg==", "license": "MIT", "engines": { "node": ">=20" @@ -1359,14 +1354,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", - "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/base64-js": { @@ -1704,9 +1699,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1741,9 +1736,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -1973,7 +1968,6 @@ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2140,9 +2134,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2153,9 +2147,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -2182,15 +2176,18 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -2594,23 +2591,10 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/Samples/Samples.Tab/Web/package.json b/Samples/Samples.Tab/Web/package.json index ec49438e1..7e6478b6e 100644 --- a/Samples/Samples.Tab/Web/package.json +++ b/Samples/Samples.Tab/Web/package.json @@ -13,10 +13,10 @@ "build": "npx vite build" }, "dependencies": { - "@microsoft/teams.client": "2.0.0-preview.11", - "@microsoft/teams.common": "2.0.0-preview.11", - "@microsoft/teams.graph": "2.0.0-preview.11", - "@microsoft/teams.graph-endpoints": "2.0.0-preview.11", + "@microsoft/teams.client": "2.0.9", + "@microsoft/teams.common": "2.0.9", + "@microsoft/teams.graph": "2.0.9", + "@microsoft/teams.graph-endpoints": "2.0.9", "react": "^19.2.1", "react-dom": "^19.2.1" }, @@ -24,6 +24,6 @@ "@vitejs/plugin-react": "^4.3.4", "rimraf": "^6.0.1", "typescript": "^5.4.5", - "vite": "^6.4.1" + "vite": "^6.4.2" } } diff --git a/Samples/Samples.TargetedMessages/Program.cs b/Samples/Samples.TargetedMessages/Program.cs index 7d43ebe81..9c5a71110 100644 --- a/Samples/Samples.TargetedMessages/Program.cs +++ b/Samples/Samples.TargetedMessages/Program.cs @@ -11,7 +11,7 @@ var teams = app.UseTeams(); // Log all incoming activities -teams.OnActivity(async context => +teams.OnActivity(async (context, cancellationToken) => { context.Log.Info($"[ACTIVITY] Type: {context.Activity.Type}, From: {context.Activity.From?.Name ?? "unknown"}"); await context.Next(); diff --git a/Samples/Samples.Threading/Program.cs b/Samples/Samples.Threading/Program.cs new file mode 100644 index 000000000..8f71893ee --- /dev/null +++ b/Samples/Samples.Threading/Program.cs @@ -0,0 +1,80 @@ +using Microsoft.Teams.Api; +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Apps.Activities; +using Microsoft.Teams.Apps.Extensions; +using Microsoft.Teams.Plugins.AspNetCore.DevTools.Extensions; +using Microsoft.Teams.Plugins.AspNetCore.Extensions; + +var builder = WebApplication.CreateBuilder(args); +builder.AddTeams().AddTeamsDevTools(); +var app = builder.Build(); +var teams = app.UseTeams(); + +teams.OnMessage(async (context, cancellationToken) => +{ + var text = (context.Activity.Text ?? "").ToLowerInvariant(); + var conversationId = context.Ref.Conversation.Id; + var messageId = context.Activity.Id!; + + // When inside a thread, conversationId contains ;messageid=. + // Extract the root ID for threading; for top-level messages, use activity.id. + var threadParts = conversationId.Split(";messageid="); + var threadRootId = threadParts.Length > 1 ? threadParts[1] : messageId; + + // ============================================ + // context.Reply() — reactive threaded reply + // ============================================ + if (text.Contains("test reply")) + { + await context.Reply("This is a threaded reply to your message.", cancellationToken); + return; + } + + // ============================================ + // context.Send() — reactive send to same thread + // ============================================ + if (text.Contains("test send")) + { + await context.Send("This is sent to the same thread, without quoting.", cancellationToken); + return; + } + + // ============================================ + // teams.Reply() — proactive threaded reply + // ============================================ + if (text.Contains("test proactive")) + { + await teams.Reply(conversationId, threadRootId, "This is a proactive threaded reply using teams.Reply().", cancellationToken); + return; + } + + // ============================================ + // ToThreadedConversationId() + teams.Send() — advanced manual control + // ============================================ + if (text.Contains("test manual")) + { + var threadId = Microsoft.Teams.Api.Conversation.ToThreadedConversationId(conversationId, threadRootId); + await teams.Send(threadId, "This was sent using ToThreadedConversationId() + teams.Send() for manual control.", cancellationToken: cancellationToken); + return; + } + + // ============================================ + // Help / Default + // ============================================ + if (text.Contains("help")) + { + await context.Reply( + "**Threading Test Bot**\n\n" + + "**Commands:**\n" + + "- `test reply` - context.Reply() reactive threaded reply\n" + + "- `test send` - context.Send() to same thread without quoting\n" + + "- `test proactive` - teams.Reply() proactive threaded reply\n" + + "- `test manual` - ToThreadedConversationId() + teams.Send() for advanced control", + cancellationToken); + return; + } + + await context.Send("Say \"help\" for available commands.", cancellationToken); +}); + +app.Run(); diff --git a/Samples/Samples.Threading/README.md b/Samples/Samples.Threading/README.md new file mode 100644 index 000000000..b7f63696e --- /dev/null +++ b/Samples/Samples.Threading/README.md @@ -0,0 +1,39 @@ +# Example: Threading + +A bot that demonstrates reactive and proactive threading in Microsoft Teams channels. + +## Commands + +| Command | Behavior | +|---------|----------| +| `test reply` | `context.Reply()` — reactive threaded reply with visual quote | +| `test send` | `context.Send()` — reactive send to same thread, no quote | +| `test proactive` | `teams.Reply()` — proactive threaded reply | +| `test manual` | `Conversation.ToThreadedConversationId()` + `teams.Send()` — advanced manual control | +| `help` | Shows available commands | + +## Notes + +- `test reply` and `test send` work in all scopes (1:1, group chat, channels) +- `test proactive` constructs a threaded conversation ID and sends to that thread +- `test manual` does the same using `Conversation.ToThreadedConversationId()` + `teams.Send()` directly +- `test proactive` and `test manual` may return a service error in conversation types that do not currently support threading (e.g. meetings) + +## Run + +```bash +dotnet run +``` + +## Configuration + +Set credentials in `appsettings.json`: + +```json +{ + "Teams": { + "ClientId": "", + "ClientSecret": "" + } +} +``` diff --git a/Samples/Samples.Threading/Samples.Threading.csproj b/Samples/Samples.Threading/Samples.Threading.csproj new file mode 100644 index 000000000..b59d514c5 --- /dev/null +++ b/Samples/Samples.Threading/Samples.Threading.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/Samples/Samples.Threading/appsettings.json b/Samples/Samples.Threading/appsettings.json new file mode 100644 index 000000000..456987037 --- /dev/null +++ b/Samples/Samples.Threading/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Microsoft.Teams": { + "Enable": "*", + "Level": "debug" + } + }, + "Teams": { + "TenantId": "", + "ClientId": "", + "ClientSecret": "" + } +} diff --git a/Tests/Microsoft.Teams.Api.Tests/Activities/Message/MessageActivityTests.cs b/Tests/Microsoft.Teams.Api.Tests/Activities/Message/MessageActivityTests.cs index d34bd1a6c..46f562f95 100644 --- a/Tests/Microsoft.Teams.Api.Tests/Activities/Message/MessageActivityTests.cs +++ b/Tests/Microsoft.Teams.Api.Tests/Activities/Message/MessageActivityTests.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Entities; namespace Microsoft.Teams.Api.Tests.Activities; @@ -547,7 +548,7 @@ public void Validate_FluentAPI() var msg = new MessageActivity("Hello") .WithDeliveryMode(DeliveryMode.Notification) .WithRecipient(new Account() { Id = "user-123", Name = "Test User", Role = Role.User }, true) - .WithImportance(Importance.High); + .WithImportance(Importance.High); Assert.Equal("Hello", msg.Text); Assert.True(msg.Recipient.IsTargeted); @@ -556,4 +557,24 @@ public void Validate_FluentAPI() Assert.Equal("Test User", msg.Recipient.Name); Assert.Equal(Role.User, msg.Recipient.Role); } + + [Fact] + public void AddStreamFinal_OverridesExistingStreamType() + { + var activity = new MessageActivity("done") + { + Id = "stream-1", + ChannelData = new ChannelData { StreamType = StreamType.Informative } + }; + + activity.AddStreamFinal(); + + Assert.Equal(StreamType.Final, activity.ChannelData.StreamType); + Assert.Equal("stream-1", activity.ChannelData.StreamId); + + var streamInfo = activity.Entities?.OfType().Single(); + Assert.NotNull(streamInfo); + Assert.Equal(StreamType.Final, streamInfo.StreamType); + Assert.Equal("stream-1", streamInfo.StreamId); + } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs new file mode 100644 index 000000000..fe9572a96 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Auth; + +namespace Microsoft.Teams.Api.Tests.Auth; + +public class CloudEnvironmentTests +{ + [Fact] + public void Public_HasCorrectEndpoints() + { + var env = CloudEnvironment.Public; + + Assert.Equal("https://login.microsoftonline.com", env.LoginEndpoint); + Assert.Equal("botframework.com", env.LoginTenant); + Assert.Equal("https://api.botframework.com/.default", env.BotScope); + Assert.Equal("https://token.botframework.com", env.TokenServiceUrl); + Assert.Equal("https://login.botframework.com/v1/.well-known/openidconfiguration", env.OpenIdMetadataUrl); + Assert.Equal("https://api.botframework.com", env.TokenIssuer); + } + + [Fact] + public void USGov_HasCorrectEndpoints() + { + var env = CloudEnvironment.USGov; + + Assert.Equal("https://login.microsoftonline.us", env.LoginEndpoint); + Assert.Equal("MicrosoftServices.onmicrosoft.us", env.LoginTenant); + Assert.Equal("https://api.botframework.us/.default", env.BotScope); + Assert.Equal("https://tokengcch.botframework.azure.us", env.TokenServiceUrl); + Assert.Equal("https://login.botframework.azure.us/v1/.well-known/openidconfiguration", env.OpenIdMetadataUrl); + Assert.Equal("https://api.botframework.us", env.TokenIssuer); + } + + [Fact] + public void USGovDoD_HasCorrectEndpoints() + { + var env = CloudEnvironment.USGovDoD; + + Assert.Equal("https://login.microsoftonline.us", env.LoginEndpoint); + Assert.Equal("MicrosoftServices.onmicrosoft.us", env.LoginTenant); + Assert.Equal("https://api.botframework.us/.default", env.BotScope); + Assert.Equal("https://apiDoD.botframework.azure.us", env.TokenServiceUrl); + Assert.Equal("https://login.botframework.azure.us/v1/.well-known/openidconfiguration", env.OpenIdMetadataUrl); + Assert.Equal("https://api.botframework.us", env.TokenIssuer); + } + + [Fact] + public void China_HasCorrectEndpoints() + { + var env = CloudEnvironment.China; + + Assert.Equal("https://login.partner.microsoftonline.cn", env.LoginEndpoint); + Assert.Equal("microsoftservices.partner.onmschina.cn", env.LoginTenant); + Assert.Equal("https://api.botframework.azure.cn/.default", env.BotScope); + Assert.Equal("https://token.botframework.azure.cn", env.TokenServiceUrl); + Assert.Equal("https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", env.OpenIdMetadataUrl); + Assert.Equal("https://api.botframework.azure.cn", env.TokenIssuer); + } + + [Theory] + [InlineData("Public", "https://login.microsoftonline.com")] + [InlineData("public", "https://login.microsoftonline.com")] + [InlineData("PUBLIC", "https://login.microsoftonline.com")] + [InlineData("USGov", "https://login.microsoftonline.us")] + [InlineData("usgov", "https://login.microsoftonline.us")] + [InlineData("USGovDoD", "https://login.microsoftonline.us")] + [InlineData("usgovdod", "https://login.microsoftonline.us")] + [InlineData("China", "https://login.partner.microsoftonline.cn")] + [InlineData("china", "https://login.partner.microsoftonline.cn")] + public void FromName_ResolvesCorrectly(string name, string expectedLoginEndpoint) + { + var env = CloudEnvironment.FromName(name); + Assert.Equal(expectedLoginEndpoint, env.LoginEndpoint); + } + + [Theory] + [InlineData("invalid")] + [InlineData("")] + [InlineData("Azure")] + public void FromName_ThrowsForUnknownName(string name) + { + Assert.Throws(() => CloudEnvironment.FromName(name)); + } + + [Fact] + public void FromName_ReturnsStaticInstances() + { + Assert.Same(CloudEnvironment.Public, CloudEnvironment.FromName("Public")); + Assert.Same(CloudEnvironment.USGov, CloudEnvironment.FromName("USGov")); + Assert.Same(CloudEnvironment.USGovDoD, CloudEnvironment.FromName("USGovDoD")); + Assert.Same(CloudEnvironment.China, CloudEnvironment.FromName("China")); + } + + [Fact] + public void WithOverrides_AllNulls_ReturnsSameInstance() + { + var env = CloudEnvironment.Public; + + var result = env.WithOverrides(); + + Assert.Same(env, result); + } + + [Fact] + public void WithOverrides_SingleOverride_ReplacesOnlyThatProperty() + { + var env = CloudEnvironment.Public; + + var result = env.WithOverrides(loginTenant: "my-tenant-id"); + + Assert.NotSame(env, result); + Assert.Equal("my-tenant-id", result.LoginTenant); + Assert.Equal(env.LoginEndpoint, result.LoginEndpoint); + Assert.Equal(env.BotScope, result.BotScope); + Assert.Equal(env.TokenServiceUrl, result.TokenServiceUrl); + Assert.Equal(env.OpenIdMetadataUrl, result.OpenIdMetadataUrl); + Assert.Equal(env.TokenIssuer, result.TokenIssuer); + } + + [Fact] + public void WithOverrides_MultipleOverrides_ReplacesCorrectProperties() + { + var env = CloudEnvironment.China; + + var result = env.WithOverrides( + loginEndpoint: "https://custom.login.cn", + loginTenant: "custom-tenant", + tokenServiceUrl: "https://custom.token.cn" + ); + + Assert.Equal("https://custom.login.cn", result.LoginEndpoint); + Assert.Equal("custom-tenant", result.LoginTenant); + Assert.Equal("https://custom.token.cn", result.TokenServiceUrl); + // unchanged + Assert.Equal(env.BotScope, result.BotScope); + Assert.Equal(env.OpenIdMetadataUrl, result.OpenIdMetadataUrl); + Assert.Equal(env.TokenIssuer, result.TokenIssuer); + } + + [Fact] + public void WithOverrides_AllOverrides_ReplacesAllProperties() + { + var env = CloudEnvironment.Public; + + var result = env.WithOverrides( + loginEndpoint: "a", + loginTenant: "b", + botScope: "c", + tokenServiceUrl: "d", + openIdMetadataUrl: "e", + tokenIssuer: "f", + graphScope: "g" + ); + + Assert.Equal("a", result.LoginEndpoint); + Assert.Equal("b", result.LoginTenant); + Assert.Equal("c", result.BotScope); + Assert.Equal("d", result.TokenServiceUrl); + Assert.Equal("e", result.OpenIdMetadataUrl); + Assert.Equal("f", result.TokenIssuer); + Assert.Equal("g", result.GraphScope); + } + + [Fact] + public void ClientCredentials_DefaultsToPublicCloud() + { + var creds = new ClientCredentials("id", "secret"); + Assert.Same(CloudEnvironment.Public, creds.Cloud); + } + + [Fact] + public void ClientCredentials_CloudCanBeSet() + { + var creds = new ClientCredentials("id", "secret") + { + Cloud = CloudEnvironment.USGov + }; + Assert.Same(CloudEnvironment.USGov, creds.Cloud); + } + + [Fact] + public void ClientCredentials_UsesCloudLoginTenantWhenTenantIdNull() + { + var creds = new ClientCredentials("id", "secret") + { + Cloud = CloudEnvironment.USGov + }; + + // TenantId is null, so Cloud.LoginTenant should be used + Assert.Null(creds.TenantId); + Assert.Equal("MicrosoftServices.onmicrosoft.us", creds.Cloud.LoginTenant); + } +} \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs b/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs index 8b01c9fbe..74a0e6d3e 100644 --- a/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs +++ b/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs @@ -147,4 +147,49 @@ public async Task BotTokenClient_HttpClientOptions_Async() Assert.Equal(expectedTenantId, actualTenantId); Assert.Equal("https://api.botframework.com/.default", actualScope[0]); } + + [Fact] + public void ActiveBotScope_DefaultsToPublicBotScope() + { + var client = new BotTokenClient(); + Assert.Equal(BotTokenClient.BotScope, client.ActiveBotScope); + Assert.Equal("https://api.botframework.com/.default", client.ActiveBotScope); + } + + [Fact] + public void ActiveBotScope_CanBeOverridden() + { + var client = new BotTokenClient(); + client.ActiveBotScope = "https://api.botframework.us/.default"; + Assert.Equal("https://api.botframework.us/.default", client.ActiveBotScope); + } + + [Fact] + public void BotScope_StaticFieldUnchanged() + { + Assert.Equal("https://api.botframework.com/.default", BotTokenClient.BotScope); + } + + [Fact] + public async Task BotTokenClient_ActiveBotScope_UsedInGetAsync() + { + var cancellationToken = new CancellationToken(); + string[] actualScope = [""]; + TokenFactory tokenFactory = new TokenFactory(async (tenantId, scope) => + { + actualScope = scope; + return await Task.FromResult(new TokenResponse + { + TokenType = "Bearer", + AccessToken = accessToken + }); + }); + var credentials = new TokenCredentials("clientId", tokenFactory); + var botTokenClient = new BotTokenClient(cancellationToken); + botTokenClient.ActiveBotScope = "https://api.botframework.us/.default"; + + await botTokenClient.GetAsync(credentials); + + Assert.Equal("https://api.botframework.us/.default", actualScope[0]); + } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/ConversationTests.cs b/Tests/Microsoft.Teams.Api.Tests/ConversationTests.cs new file mode 100644 index 000000000..dc6dfa979 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/ConversationTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Api.Tests; + +public class ConversationTests +{ + [Fact] + public void ToThreadedConversationId_ConstructsThreadedConversationId() + { + var result = Conversation.ToThreadedConversationId("19:abc@thread.skype", "1680000000000"); + Assert.Equal("19:abc@thread.skype;messageid=1680000000000", result); + } + + [Fact] + public void ToThreadedConversationId_WorksWithDifferentConversationIdFormats() + { + var result = Conversation.ToThreadedConversationId("19:meeting_abc@thread.v2", "999"); + Assert.Equal("19:meeting_abc@thread.v2;messageid=999", result); + } + + [Fact] + public void ToThreadedConversationId_ThrowsOnEmptyConversationId() + { + Assert.Throws(() => Conversation.ToThreadedConversationId("", "123")); + } + + [Fact] + public void ToThreadedConversationId_ThrowsOnNullConversationId() + { + Assert.Throws(() => Conversation.ToThreadedConversationId(null!, "123")); + } + + [Fact] + public void ToThreadedConversationId_ThrowsOnEmptyMessageId() + { + Assert.Throws(() => Conversation.ToThreadedConversationId("19:abc@thread.skype", "")); + } + + [Fact] + public void ToThreadedConversationId_ThrowsOnZeroMessageId() + { + Assert.Throws(() => Conversation.ToThreadedConversationId("19:abc@thread.skype", "0")); + } + + [Fact] + public void ToThreadedConversationId_ThrowsOnNonNumericMessageId() + { + Assert.Throws(() => Conversation.ToThreadedConversationId("19:abc@thread.skype", "abc")); + } + + [Fact] + public void ToThreadedConversationId_ThrowsOnNegativeMessageId() + { + Assert.Throws(() => Conversation.ToThreadedConversationId("19:abc@thread.skype", "-1")); + } + + [Fact] + public void ToThreadedConversationId_ThrowsOnDecimalMessageId() + { + Assert.Throws(() => Conversation.ToThreadedConversationId("19:abc@thread.skype", "1.5")); + } + + [Fact] + public void ToThreadedConversationId_StripsExistingMessageIdAndReplacesWithThreadRoot() + { + var result = Conversation.ToThreadedConversationId("19:abc@thread.skype;messageid=111", "222"); + Assert.Equal("19:abc@thread.skype;messageid=222", result); + } +} diff --git a/Tests/Microsoft.Teams.Api.Tests/Entities/QuotedReplyEntityTests.cs b/Tests/Microsoft.Teams.Api.Tests/Entities/QuotedReplyEntityTests.cs new file mode 100644 index 000000000..32cffb319 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Entities/QuotedReplyEntityTests.cs @@ -0,0 +1,253 @@ +using System.Text.Json; + +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Entities; + +namespace Microsoft.Teams.Api.Tests.Entities; + +public class QuotedReplyEntityTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + IndentSize = 2, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + [Fact] + public void QuotedReplyEntity_JsonSerialize() + { + var entity = new QuotedReplyEntity() + { + QuotedReply = new QuotedReplyData() + { + MessageId = "1234567890", + SenderId = "user-1", + SenderName = "Test User", + Preview = "Hello, world!", + Time = "1772050244572", + IsReplyDeleted = false, + ValidatedMessageReference = true + } + }; + + var json = JsonSerializer.Serialize(entity, JsonOptions); + + Assert.Equal(File.ReadAllText( + @"../../../Json/Entities/QuotedReplyEntity.json" + ), json); + } + + [Fact] + public void QuotedReplyEntity_JsonSerialize_Derived() + { + Entity entity = new QuotedReplyEntity() + { + QuotedReply = new QuotedReplyData() + { + MessageId = "1234567890", + SenderId = "user-1", + SenderName = "Test User", + Preview = "Hello, world!", + Time = "1772050244572", + IsReplyDeleted = false, + ValidatedMessageReference = true + } + }; + + var json = JsonSerializer.Serialize(entity, JsonOptions); + + Assert.Equal(File.ReadAllText( + @"../../../Json/Entities/QuotedReplyEntity.json" + ), json); + } + + [Fact] + public void QuotedReplyEntity_JsonDeserialize() + { + var json = File.ReadAllText(@"../../../Json/Entities/QuotedReplyEntity.json"); + var entity = JsonSerializer.Deserialize(json); + + Assert.NotNull(entity); + Assert.Equal("quotedReply", entity.Type); + Assert.NotNull(entity.QuotedReply); + Assert.Equal("1234567890", entity.QuotedReply.MessageId); + Assert.Equal("user-1", entity.QuotedReply.SenderId); + Assert.Equal("Test User", entity.QuotedReply.SenderName); + Assert.Equal("Hello, world!", entity.QuotedReply.Preview); + Assert.Equal("1772050244572", entity.QuotedReply.Time); + Assert.Equal(false, entity.QuotedReply.IsReplyDeleted); + Assert.Equal(true, entity.QuotedReply.ValidatedMessageReference); + } + + [Fact] + public void QuotedReplyEntity_JsonDeserialize_Derived() + { + var json = File.ReadAllText(@"../../../Json/Entities/QuotedReplyEntity.json"); + var entity = JsonSerializer.Deserialize(json); + + Assert.NotNull(entity); + Assert.IsType(entity); + var quotedReply = (QuotedReplyEntity)entity; + Assert.Equal("quotedReply", quotedReply.Type); + Assert.NotNull(quotedReply.QuotedReply); + Assert.Equal("1234567890", quotedReply.QuotedReply.MessageId); + } + + [Fact] + public void QuotedReplyEntity_RoundTrip() + { + var json = File.ReadAllText(@"../../../Json/Entities/QuotedReplyEntity.json"); + var entity = JsonSerializer.Deserialize(json); + var reserialized = JsonSerializer.Serialize(entity, JsonOptions); + + Assert.Equal(json, reserialized); + } + + [Fact] + public void QuotedReplyEntity_MinimalData() + { + var entity = new QuotedReplyEntity() + { + QuotedReply = new QuotedReplyData() + { + MessageId = "msg-1" + } + }; + + var json = JsonSerializer.Serialize(entity, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.Equal("quotedReply", deserialized.Type); + Assert.NotNull(deserialized.QuotedReply); + Assert.Equal("msg-1", deserialized.QuotedReply.MessageId); + Assert.Null(deserialized.QuotedReply.SenderId); + Assert.Null(deserialized.QuotedReply.SenderName); + Assert.Null(deserialized.QuotedReply.Preview); + Assert.Null(deserialized.QuotedReply.Time); + Assert.Null(deserialized.QuotedReply.IsReplyDeleted); + Assert.Null(deserialized.QuotedReply.ValidatedMessageReference); + } + + [Fact] + public void GetQuotedMessages_Getter_FiltersCorrectly() + { + var message = new MessageActivity("test"); + message.Entities = new List + { + new ClientInfoEntity() { Locale = "en-us" }, + new QuotedReplyEntity() + { + QuotedReply = new QuotedReplyData() { MessageId = "msg-1" } + }, + new MentionEntity() + { + Mentioned = new Account() { Id = "user-1", Name = "User" }, + Text = "User" + }, + new QuotedReplyEntity() + { + QuotedReply = new QuotedReplyData() { MessageId = "msg-2" } + } + }; + + var quotedReplies = message.GetQuotedMessages(); + + Assert.Equal(2, quotedReplies.Count); + Assert.Equal("msg-1", quotedReplies[0].QuotedReply?.MessageId); + Assert.Equal("msg-2", quotedReplies[1].QuotedReply?.MessageId); + } + + [Fact] + public void GetQuotedMessages_Getter_EmptyWhenNoEntities() + { + var message = new MessageActivity("test"); + message.Entities = null; + + var quotedReplies = message.GetQuotedMessages(); + + Assert.Empty(quotedReplies); + } + + [Fact] + public void GetQuotedMessages_Getter_EmptyWhenNoQuotedReplyEntities() + { + var message = new MessageActivity("test"); + message.Entities = new List + { + new ClientInfoEntity() { Locale = "en-us" } + }; + + var quotedReplies = message.GetQuotedMessages(); + + Assert.Empty(quotedReplies); + } + + [Fact] + public void AddQuote_AddsEntityAndPlaceholder() + { + var message = new MessageActivity().AddQuote("msg-1"); + + Assert.Single(message.Entities!); + Assert.Equal("quotedReply", message.Entities![0].Type); + Assert.Contains("", message.Text); + } + + [Fact] + public void AddQuote_WithResponse_AppendsResponseText() + { + var message = new MessageActivity().AddQuote("msg-1", "my response"); + + Assert.Equal(" my response", message.Text); + } + + [Fact] + public void AddQuote_MultiQuoteInterleaved() + { + var message = new MessageActivity() + .AddQuote("msg-1", "response to first") + .AddQuote("msg-2", "response to second"); + + Assert.Equal( + " response to first response to second", + message.Text); + Assert.Equal(2, message.Entities!.Count); + } + + [Fact] + public void AddQuote_GroupedQuotes() + { + var message = new MessageActivity() + .AddQuote("msg-1") + .AddQuote("msg-2", "response to both"); + + Assert.Equal( + " response to both", + message.Text); + } + + [Fact] +#pragma warning disable CS0618 // Obsolete + public void ToQuoteReply_ReturnsModernPlaceholder() + { + var message = new MessageActivity("test") { Id = "activity-123" }; + + var result = message.ToQuoteReply(); + + Assert.Equal("", result); + } +#pragma warning restore CS0618 + + [Fact] +#pragma warning disable CS0618 // Obsolete + public void ToQuoteReply_ReturnsEmptyWhenNoId() + { + var message = new MessageActivity("test"); + + var result = message.ToQuoteReply(); + + Assert.Equal(string.Empty, result); + } +#pragma warning restore CS0618 +} \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/Json/Entities/QuotedReplyEntity.json b/Tests/Microsoft.Teams.Api.Tests/Json/Entities/QuotedReplyEntity.json new file mode 100644 index 000000000..cff79c9c3 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Json/Entities/QuotedReplyEntity.json @@ -0,0 +1,12 @@ +{ + "type": "quotedReply", + "quotedReply": { + "messageId": "1234567890", + "senderId": "user-1", + "senderName": "Test User", + "preview": "Hello, world!", + "time": "1772050244572", + "isReplyDeleted": false, + "validatedMessageReference": true + } +} \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/Microsoft.Teams.Api.Tests.csproj b/Tests/Microsoft.Teams.Api.Tests/Microsoft.Teams.Api.Tests.csproj index 0fe92a155..a2b08b5ed 100644 --- a/Tests/Microsoft.Teams.Api.Tests/Microsoft.Teams.Api.Tests.csproj +++ b/Tests/Microsoft.Teams.Api.Tests/Microsoft.Teams.Api.Tests.csproj @@ -6,7 +6,7 @@ enable false latest - $(NoWarn);ExperimentalTeamsReactions;ExperimentalTeamsTargeted + $(NoWarn);ExperimentalTeamsReactions;ExperimentalTeamsTargeted;ExperimentalTeamsQuotedReplies diff --git a/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/Messages/FetchTaskActivityTests.cs b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/Messages/FetchTaskActivityTests.cs new file mode 100644 index 000000000..8ecfa1ee9 --- /dev/null +++ b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/Messages/FetchTaskActivityTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Activities.Invokes; +using Microsoft.Teams.Api.Auth; +using Microsoft.Teams.Apps.Activities.Invokes; +using Microsoft.Teams.Apps.Annotations; +using Microsoft.Teams.Apps.Testing.Plugins; + +using TaskModules = Microsoft.Teams.Api.TaskModules; + +namespace Microsoft.Teams.Apps.Tests.Activities; + +public class FetchTaskActivityTests +{ + private readonly App _app = new(); + private readonly IToken _token = Globals.Token; + private readonly Controller _controller = new(); + + public FetchTaskActivityTests() + { + _app.AddPlugin(new TestPlugin()); + _app.AddController(_controller); + } + + private static Messages.FetchTaskActivity SetupFetchTaskActivity(string reaction = "like") + { + return new Messages.FetchTaskActivity + { + Value = new Messages.FetchTaskActivity.FetchTaskValue + { + Data = new Messages.FetchTaskActivity.FetchTaskData + { + ActionValue = new Messages.FetchTaskActivity.FetchTaskActionValue + { + Reaction = new Reaction(reaction), + }, + }, + }, + }; + } + + [Fact] + public async Task Should_CallHandler() + { + var calls = 0; + + _app.OnMessageFetchTask((context, ct) => + { + calls++; + Assert.True(context.Activity.Type.IsInvoke); + Assert.True(context.Activity.Name == Name.Messages.FetchTask); + Assert.True(context.Activity.Value.Data.ActionValue.Reaction.IsLike); + return Task.FromResult(new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo { Title = "Feedback" }))); + }); + + var res = await _app.Process(_token, SetupFetchTaskActivity()); + + Assert.Equal(System.Net.HttpStatusCode.OK, res.Status); + Assert.Equal(1, calls); + Assert.Equal(1, _controller.Calls); + Assert.Equal(2, res.Meta.Routes); + } + + [Fact] + public async Task Should_Not_CallHandler_OnOtherActivity() + { + var calls = 0; + + _app.OnMessageFetchTask((context, ct) => + { + calls++; + return Task.FromResult(new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo()))); + }); + + var res = await _app.Process(_token, new TypingActivity()); + + Assert.Equal(System.Net.HttpStatusCode.OK, res.Status); + Assert.Equal(0, calls); + Assert.Equal(0, _controller.Calls); + Assert.Equal(0, res.Meta.Routes); + } + + [Fact] + public async Task Should_Not_CallHandler_OnSubmitAction() + { + var calls = 0; + + _app.OnMessageFetchTask((context, ct) => + { + calls++; + return Task.FromResult(new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo()))); + }); + + var submit = new Messages.SubmitActionActivity + { + Value = new Messages.SubmitActionActivity.SubmitActionValue + { + ActionName = "feedback", + ActionValue = "test", + }, + }; + + var res = await _app.Process(_token, submit); + + Assert.Equal(System.Net.HttpStatusCode.OK, res.Status); + Assert.Equal(0, calls); + } + + [Fact] + public async Task Should_CallHandler_OnDislikeReaction() + { + var calls = 0; + + _app.OnMessageFetchTask((context, ct) => + { + calls++; + Assert.True(context.Activity.Value.Data.ActionValue.Reaction.IsDislike); + return Task.FromResult(new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo()))); + }); + + var res = await _app.Process(_token, SetupFetchTaskActivity("dislike")); + + Assert.Equal(System.Net.HttpStatusCode.OK, res.Status); + Assert.Equal(1, calls); + } + + [TeamsController] + public class Controller + { + public int Calls { get; private set; } + + [Microsoft.Teams.Apps.Activities.Invokes.Message.FetchTask] + public void OnFetchTask([Context] IContext.Next next) + { + Calls++; + next(); + } + } +} + diff --git a/Tests/Microsoft.Teams.Apps.Tests/AppTests.cs b/Tests/Microsoft.Teams.Apps.Tests/AppTests.cs index cdfc6300c..5ce7a0614 100644 --- a/Tests/Microsoft.Teams.Apps.Tests/AppTests.cs +++ b/Tests/Microsoft.Teams.Apps.Tests/AppTests.cs @@ -300,4 +300,71 @@ public void Test_App_Send_TargetedMessage_WithRecipient_PassesValidation() Assert.Equal("user123", targetedMessage.Recipient.Id); } + [Fact] + public async Task Test_App_Reply_ThreeArgs_ConstructsThreadedId() + { + // arrange + var sender = new Mock(); + sender.Setup(s => s.Send(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((MessageActivity a, ConversationReference r, CancellationToken c) => a); + + var token = new Mock(); + token.Setup(t => t.AppId).Returns("test-app-id"); + token.Setup(t => t.AppDisplayName).Returns("Test App"); + + var options = new AppOptions() { Plugins = [sender.Object] }; + var app = new App(options); + app.Token = token.Object; + + // act + await app.Reply("19:abc@thread.skype", "1680000000000", new MessageActivity("Hello thread")); + + // assert + sender.Verify(s => s.Send( + It.IsAny(), + It.Is(r => r.Conversation.Id == "19:abc@thread.skype;messageid=1680000000000"), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task Test_App_Reply_TwoArgs_PassesConversationIdAsIs() + { + // arrange + var sender = new Mock(); + sender.Setup(s => s.Send(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((MessageActivity a, ConversationReference r, CancellationToken c) => a); + + var token = new Mock(); + token.Setup(t => t.AppId).Returns("test-app-id"); + token.Setup(t => t.AppDisplayName).Returns("Test App"); + + var options = new AppOptions() { Plugins = [sender.Object] }; + var app = new App(options); + app.Token = token.Object; + + // act + await app.Reply("19:abc@thread.skype", new MessageActivity("Hello flat")); + + // assert + sender.Verify(s => s.Send( + It.IsAny(), + It.Is(r => r.Conversation.Id == "19:abc@thread.skype"), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task Test_App_Reply_ThreeArgs_InvalidMessageId_Throws() + { + // arrange + var credentials = new Mock(); + var options = new AppOptions() { Credentials = credentials.Object }; + var app = new App(options); + + // act & assert + await Assert.ThrowsAsync(() => + app.Reply("19:abc@thread.skype", "not-a-number", new MessageActivity("Hello"))); + } + } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Apps.Tests/Contexts/ContextQuotedReplyTests.cs b/Tests/Microsoft.Teams.Apps.Tests/Contexts/ContextQuotedReplyTests.cs new file mode 100644 index 000000000..7e698c49a --- /dev/null +++ b/Tests/Microsoft.Teams.Apps.Tests/Contexts/ContextQuotedReplyTests.cs @@ -0,0 +1,232 @@ +using Microsoft.Teams.Api; +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Auth; +using Microsoft.Teams.Api.Entities; +using Microsoft.Teams.Apps.Activities; +using Microsoft.Teams.Apps.Testing.Plugins; + +namespace Microsoft.Teams.Apps.Tests.Contexts; + +public class ContextQuotedReplyTests +{ + private readonly IToken _token = Globals.Token; + + private static MessageActivity CreateInbound(string text, string? id = "msg-123") + { + return new MessageActivity(text) + { + Id = id, + From = new Account { Id = "user1" }, + Recipient = new Account { Id = "bot1" }, + Conversation = new Api.Conversation { Id = "conv1" } + }; + } + + [Fact] + public async Task Reply_Should_Stamp_QuotedReplyEntity_With_ActivityId() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Reply(new MessageActivity("reply text")); + }); + + await app.Process(_token, CreateInbound("hello", "msg-123")); + + Assert.NotNull(sent); + var quotedEntity = Assert.Single(sent!.Entities!.OfType()); + Assert.Equal("msg-123", quotedEntity.QuotedReply.MessageId); + } + + [Fact] + public async Task Reply_Should_Prepend_Placeholder_To_Text() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Reply(new MessageActivity("reply text")); + }); + + await app.Process(_token, CreateInbound("hello", "msg-123")); + + Assert.NotNull(sent); + Assert.StartsWith("", sent!.Text); + Assert.Contains("reply text", sent.Text); + } + + [Fact] + public async Task Reply_Should_Handle_Empty_Text() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Reply(new MessageActivity()); + }); + + await app.Process(_token, CreateInbound("hello", "msg-456")); + + Assert.NotNull(sent); + Assert.Equal("", sent!.Text); + } + + [Fact] + public async Task Reply_Should_Not_Stamp_Entity_When_ActivityId_Is_Null() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Reply(new MessageActivity("reply text")); + }); + + await app.Process(_token, CreateInbound("hello", null)); + + Assert.NotNull(sent); + var quotedEntities = (sent!.Entities ?? new List()).OfType().ToList(); + Assert.Empty(quotedEntities); + } + + [Fact] + public async Task Reply_Should_Preserve_Existing_Entities() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + var activity = new MessageActivity("reply text"); + activity.Entities = new List + { + new MentionEntity { Mentioned = new Account { Id = "user2", Name = "User Two" }, Text = "User Two" } + }; + sent = await context.Reply(activity); + }); + + await app.Process(_token, CreateInbound("hello", "msg-789")); + + Assert.NotNull(sent); + Assert.Equal(2, sent!.Entities!.Count); + Assert.Single(sent.Entities.OfType()); + Assert.Single(sent.Entities.OfType()); + } + + [Fact] + public async Task Quote_Should_Stamp_Entity_With_Provided_MessageId() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Quote("custom-msg-id", new MessageActivity("quote reply text")); + }); + + await app.Process(_token, CreateInbound("hello", "msg-000")); + + Assert.NotNull(sent); + var quotedEntity = Assert.Single(sent!.Entities!.OfType()); + Assert.Equal("custom-msg-id", quotedEntity.QuotedReply.MessageId); + } + + [Fact] + public async Task Quote_Should_Prepend_Placeholder_To_Text() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Quote("custom-msg-id", new MessageActivity("quote reply text")); + }); + + await app.Process(_token, CreateInbound("hello", "msg-000")); + + Assert.NotNull(sent); + Assert.StartsWith("", sent!.Text); + Assert.Contains("quote reply text", sent.Text); + } + + [Fact] + public async Task Quote_Should_Handle_Empty_Text() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Quote("custom-msg-id", new MessageActivity()); + }); + + await app.Process(_token, CreateInbound("hello", "msg-000")); + + Assert.NotNull(sent); + Assert.Equal("", sent!.Text); + } + + [Fact] + public async Task Quote_String_Overload_Should_Stamp_Entity_And_Prepend_Placeholder() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Quote("custom-msg-id", "quote reply text"); + }); + + await app.Process(_token, CreateInbound("hello", "msg-000")); + + Assert.NotNull(sent); + var quotedEntity = Assert.Single(sent!.Entities!.OfType()); + Assert.Equal("custom-msg-id", quotedEntity.QuotedReply.MessageId); + Assert.StartsWith("", sent.Text); + Assert.Contains("quote reply text", sent.Text); + } + + [Fact] + public async Task Reply_String_Overload_Should_Stamp_Entity_And_Prepend_Placeholder() + { + var app = new App(); + app.AddPlugin(new TestPlugin()); + + MessageActivity? sent = null; + + app.OnMessage(async context => + { + sent = await context.Reply("reply text"); + }); + + await app.Process(_token, CreateInbound("hello", "msg-123")); + + Assert.NotNull(sent); + var quotedEntity = Assert.Single(sent!.Entities!.OfType()); + Assert.Equal("msg-123", quotedEntity.QuotedReply.MessageId); + Assert.StartsWith("", sent.Text); + Assert.Contains("reply text", sent.Text); + } +} \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Apps.Tests/Microsoft.Teams.Apps.Tests.csproj b/Tests/Microsoft.Teams.Apps.Tests/Microsoft.Teams.Apps.Tests.csproj index 62e886fd0..983eb89f8 100644 --- a/Tests/Microsoft.Teams.Apps.Tests/Microsoft.Teams.Apps.Tests.csproj +++ b/Tests/Microsoft.Teams.Apps.Tests/Microsoft.Teams.Apps.Tests.csproj @@ -5,7 +5,7 @@ enable enable false - $(NoWarn);CS0618;ExperimentalTeamsReactions;ExperimentalTeamsTargeted + $(NoWarn);CS0618;ExperimentalTeamsReactions;ExperimentalTeamsTargeted;ExperimentalTeamsQuotedReplies diff --git a/Tests/Microsoft.Teams.Cards.Tests/AdaptiveCardsTest.cs b/Tests/Microsoft.Teams.Cards.Tests/AdaptiveCardsTest.cs index 44e92bbb9..f33a9b00c 100644 --- a/Tests/Microsoft.Teams.Cards.Tests/AdaptiveCardsTest.cs +++ b/Tests/Microsoft.Teams.Cards.Tests/AdaptiveCardsTest.cs @@ -10,13 +10,10 @@ public class AdaptiveCardsTest public void Should_Serialize_AdaptiveCard_Simple() { // arrange - AdaptiveCard card = new AdaptiveCard() - { - Body = new List() + AdaptiveCard card = new AdaptiveCard(new List { new TextBlock("Hello, Adaptive Card!") - } - }; + }); // act var json = JsonSerializer.Serialize(card); @@ -58,10 +55,7 @@ public void Should_Deserialize_AdaptiveCard_Simple() public void Should_Serialize_BasicCard_WithToggleInput() { // arrange - recreating CreateBasicAdaptiveCard from samples - var card = new AdaptiveCard - { - Schema = "http://adaptivecards.io/schemas/adaptive-card.json", - Body = new List + var card = new AdaptiveCard(new List { new TextBlock("Hello world") { @@ -72,7 +66,9 @@ public void Should_Serialize_BasicCard_WithToggleInput() { Id = "notify" } - }, + }) + { + Schema = "http://adaptivecards.io/schemas/adaptive-card.json", Actions = new List { new ExecuteAction @@ -153,7 +149,7 @@ public void Should_Deserialize_ProfileCard_WithInputs() ] }"; - var card = JsonSerializer.Deserialize(cardJson); + var card = JsonSerializer.Deserialize(json); Assert.NotNull(card); // Note: Schema might be serialized as $schema in JSON but not always set on deserialized object @@ -190,10 +186,7 @@ public void Should_Deserialize_ProfileCard_WithInputs() public void Should_Serialize_TaskFormCard_WithChoiceSet() { // arrange - recreating CreateTaskFormCard from samples - var card = new AdaptiveCard - { - Schema = "http://adaptivecards.io/schemas/adaptive-card.json", - Body = new List + var card = new AdaptiveCard(new List { new TextBlock("Create New Task") { @@ -206,17 +199,16 @@ public void Should_Serialize_TaskFormCard_WithChoiceSet() Label = "Task Title", Placeholder = "Enter task title" }, - new ChoiceSetInput - { - Id = "priority", - Label = "Priority", - Value = "medium", - Choices = new List + new ChoiceSetInput(new List { new() { Title = "High", Value = "high" }, new() { Title = "Medium", Value = "medium" }, new() { Title = "Low", Value = "low" } - } + }) + { + Id = "priority", + Label = "Priority", + Value = "medium" }, new DateInput { @@ -224,7 +216,9 @@ public void Should_Serialize_TaskFormCard_WithChoiceSet() Label = "Due Date", Value = "2024-01-15" } - } + }) + { + Schema = "http://adaptivecards.io/schemas/adaptive-card.json" }; // act @@ -350,10 +344,7 @@ public void Should_Deserialize_ComplexCard_FromJson() public void Should_Serialize_FeedbackCard_WithMultilineInput() { // arrange - recreating CreateFeedbackCard from samples - var card = new AdaptiveCard - { - Schema = "http://adaptivecards.io/schemas/adaptive-card.json", - Body = new List + var card = new AdaptiveCard(new List { new TextBlock("Feedback Form") { @@ -368,7 +359,9 @@ public void Should_Serialize_FeedbackCard_WithMultilineInput() IsMultiline = true, IsRequired = true } - }, + }) + { + Schema = "http://adaptivecards.io/schemas/adaptive-card.json", Actions = new List { new ExecuteAction @@ -433,7 +426,7 @@ public void Should_Deserialize_ValidationCard_WithNumberInput() ] }"; - var card = JsonSerializer.Deserialize(cardJson); + var card = JsonSerializer.Deserialize(json); Assert.NotNull(card); Assert.Equal(3, card.Body!.Count); @@ -512,12 +505,11 @@ public void Should_Deserialize() public void Should_Not_Serialize_Null_MsTeams_Property_On_SubmitAction() { // arrange - var card = new AdaptiveCard - { - Body = new List + var card = new AdaptiveCard(new List { new TextBlock("Test card with Submit action") - }, + }) + { Actions = new List { new SubmitAction @@ -564,4 +556,63 @@ public void Should_Serialize_Actions() Assert.Equal("Learn More", action.Title); Assert.Equal("https://adaptivecards.microsoft.com", action.Url); } + + [Fact] + public void SubmitData_Should_Set_Action_Field() + { + var data = new SubmitData("save_profile"); + + var json = JsonSerializer.Serialize(data); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("action", out var actionElement)); + Assert.Equal("save_profile", actionElement.GetString()); + } + + [Fact] + public void SubmitData_Should_Include_Extra_Data() + { + var data = new SubmitData("save_profile", new Dictionary { ["entity_id"] = "12345" }); + + var json = JsonSerializer.Serialize(data); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("action", out var actionElement)); + Assert.Equal("save_profile", actionElement.GetString()); + Assert.True(root.TryGetProperty("entity_id", out var entityElement)); + Assert.Equal("12345", entityElement.GetString()); + } + + [Fact] + public void OpenDialogData_Should_Set_Msteams_And_DialogId() + { + var data = new OpenDialogData("simple_form"); + + var json = JsonSerializer.Serialize(data); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("msteams", out var msteamsElement)); + Assert.Equal("task/fetch", msteamsElement.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("dialog_id", out var dialogIdElement)); + Assert.Equal("simple_form", dialogIdElement.GetString()); + } + + [Fact] + public void OpenDialogData_Should_Include_Extra_Data() + { + var data = new OpenDialogData("simple_form", new Dictionary { ["custom_key"] = "value" }); + + var json = JsonSerializer.Serialize(data); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("msteams", out _)); + Assert.True(root.TryGetProperty("dialog_id", out var dialogIdElement)); + Assert.Equal("simple_form", dialogIdElement.GetString()); + Assert.True(root.TryGetProperty("custom_key", out var customElement)); + Assert.Equal("value", customElement.GetString()); + } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginStreamTests.cs b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginStreamTests.cs index 68125a576..973793abf 100644 --- a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginStreamTests.cs +++ b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginStreamTests.cs @@ -117,4 +117,92 @@ public async Task Stream_UpdateStatus_SendsTypingActivity() Assert.Equal(StreamType.Informative, ((TypingActivity)sentActivity).ChannelData?.StreamType); } + [Fact] + public async Task Stream_Close_FinalMessageHasStreamTypeFinal_AfterInformativeUpdate() + { + var sendCallCount = 0; + var stream = new AspNetCorePlugin.Stream + { + Send = activity => + { + sendCallCount++; + activity.Id = $"id-{sendCallCount}"; + return Task.FromResult(activity); + } + }; + + // Update + Emit both queue activities; Close() waits for the flush to drain the + // queue and complete, so no fixed sleeps are needed. + stream.Update("Thinking..."); + stream.Emit("Done"); + + var result = await stream.Close(); + + Assert.NotNull(result); + // Final message must have StreamType.Final, not the accumulated Informative + // from the prior typing update. + Assert.Equal(StreamType.Final, result.ChannelData?.StreamType); + + // The streaminfo entity on the final message should also be Final. + var streamInfo = result.Entities?.OfType().Single(); + Assert.NotNull(streamInfo); + Assert.Equal(StreamType.Final, streamInfo.StreamType); + } + + [Fact] + public async Task Stream_Close_WaitsForInFlightFlushToComplete() + { + var sendCallCount = 0; + var firstSendCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondSendStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondSendRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stream = new AspNetCorePlugin.Stream + { + Send = async activity => + { + sendCallCount++; + var thisCall = sendCallCount; + if (thisCall == 2) + { + secondSendStarted.TrySetResult(true); + await secondSendRelease.Task; + } + activity.Id = $"id-{thisCall}"; + if (thisCall == 1) firstSendCompleted.TrySetResult(true); + return activity; + } + }; + + // First flush: emit and wait for the send to actually complete (deterministic + // signal from the Send delegate). _id is assigned by the SendActivity helper after + // the await — yielding once lets that post-await code run before we proceed. + stream.Emit("chunk 1"); + await firstSendCompleted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await Task.Yield(); + Assert.Equal(1, sendCallCount); + + // Second flush: Send blocks → queue drained, _id set, _lock held. + stream.Emit("chunk 2"); + await secondSendStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + var closeTask = stream.Close(); + + // With the race-fix, Close() must not progress past its wait loop while the + // flush is mid-await. Pre-fix, closeTask would race ahead and call Send for the + // final activity (sendCallCount → 3) before we release the second flush. + // We yield several times rather than sleep — Close() polls every 50ms and we + // want to give it ample chance to make progress if the bug is present. + for (var i = 0; i < 10; i++) await Task.Yield(); + await Task.Delay(100); + Assert.False(closeTask.IsCompleted); + Assert.Equal(2, sendCallCount); + + // Releasing the second flush lets the lock drop, and Close() then sends the final. + secondSendRelease.SetResult(true); + + var result = await closeTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.NotNull(result); + Assert.Equal(3, sendCallCount); + } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs new file mode 100644 index 000000000..49658400d --- /dev/null +++ b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Auth; +using Microsoft.Teams.Plugins.AspNetCore.Extensions; + +namespace Microsoft.Teams.Plugins.AspNetCore.Tests.Extensions; + +public class TeamsValidationSettingsTests +{ + [Fact] + public void DefaultConstructor_UsesPublicCloud() + { + var settings = new TeamsValidationSettings(); + + Assert.Equal("https://login.botframework.com/v1/.well-known/openidconfiguration", settings.OpenIdMetadataUrl); + Assert.Equal("https://login.microsoftonline.com", settings.LoginEndpoint); + Assert.Contains("https://api.botframework.com", settings.Issuers); + } + + [Fact] + public void USGovCloud_HasCorrectSettings() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + Assert.Equal("https://login.botframework.azure.us/v1/.well-known/openidconfiguration", settings.OpenIdMetadataUrl); + Assert.Equal("https://login.microsoftonline.us", settings.LoginEndpoint); + Assert.Contains("https://api.botframework.us", settings.Issuers); + } + + [Fact] + public void ChinaCloud_HasCorrectSettings() + { + var settings = new TeamsValidationSettings(CloudEnvironment.China); + + Assert.Equal("https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", settings.OpenIdMetadataUrl); + Assert.Equal("https://login.partner.microsoftonline.cn", settings.LoginEndpoint); + Assert.Contains("https://api.botframework.azure.cn", settings.Issuers); + } + + [Fact] + public void AllClouds_IncludeEmulatorIssuers() + { + var clouds = new[] { CloudEnvironment.Public, CloudEnvironment.USGov, CloudEnvironment.USGovDoD, CloudEnvironment.China }; + + foreach (var cloud in clouds) + { + var settings = new TeamsValidationSettings(cloud); + + // Emulator issuers should always be present + Assert.Contains(settings.Issuers, i => i.Contains("d6d49420-f39b-4df7-a1dc-d59a935871db")); + Assert.Contains(settings.Issuers, i => i.Contains("f8cdef31-a31e-4b4a-93e4-5f571e91255a")); + } + } + + [Fact] + public void GetTenantSpecificOpenIdMetadataUrl_UsesCloudLoginEndpoint() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + var url = settings.GetTenantSpecificOpenIdMetadataUrl("my-tenant"); + + Assert.Equal("https://login.microsoftonline.us/my-tenant/v2.0/.well-known/openid-configuration", url); + } + + [Fact] + public void GetTenantSpecificOpenIdMetadataUrl_DefaultsToCommon() + { + var settings = new TeamsValidationSettings(CloudEnvironment.China); + + var url = settings.GetTenantSpecificOpenIdMetadataUrl(null); + + Assert.Equal("https://login.partner.microsoftonline.cn/common/v2.0/.well-known/openid-configuration", url); + } + + [Fact] + public void GetValidIssuersForTenant_UsesCloudLoginEndpoint() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + var issuers = settings.GetValidIssuersForTenant("my-tenant").ToList(); + + Assert.Single(issuers); + Assert.Equal("https://login.microsoftonline.us/my-tenant/", issuers[0]); + } + + [Fact] + public void GetValidIssuersForTenant_ReturnsEmptyForNullTenant() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + var issuers = settings.GetValidIssuersForTenant(null).ToList(); + + Assert.Empty(issuers); + } + + [Fact] + public void AddDefaultAudiences_AddsClientIdAndApiPrefix() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + settings.AddDefaultAudiences("my-client-id"); + + Assert.Contains("my-client-id", settings.Audiences); + Assert.Contains("api://my-client-id", settings.Audiences); + } +} \ No newline at end of file diff --git a/core/.editorconfig b/core/.editorconfig new file mode 100644 index 000000000..346b8458d --- /dev/null +++ b/core/.editorconfig @@ -0,0 +1,40 @@ +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 + +#### 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 performance 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 000000000..31b93611d --- /dev/null +++ b/core/.gitignore @@ -0,0 +1,9 @@ +launchSettings.json +appsettings.Development.json +*.runsettings +.claude/ + + +# Web build output and dependencies +**/Web/bin/ +**/Web/node_modules/ diff --git a/core/README.md b/core/README.md new file mode 100644 index 000000000..c69e3eb5e --- /dev/null +++ b/core/README.md @@ -0,0 +1,126 @@ +# Microsoft Teams Bot Core SDK + +The core SDK for building Microsoft Teams bots in .NET. It implements the [Activity Protocol](https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md) and provides a modern, layered framework with first-class support for ASP.NET Core dependency injection, authentication via MSAL, and extensible activity schemas. + +## Packages + +| Package | Description | +|---------|-------------| +| [Microsoft.Teams.Core](src/Microsoft.Teams.Core/) | Foundational library — activity protocol, conversation client, user token client, middleware pipeline, and authentication | +| [Microsoft.Teams.Apps](src/Microsoft.Teams.Apps/) | High-level Teams framework — typed activity routing, handler registration, OAuth flows, Teams API clients, and streaming | +| [Microsoft.Teams.Apps.BotBuilder](src/Microsoft.Teams.Apps.BotBuilder/) | Compatibility bridge for existing Bot Framework SDK v4 bots to run on the new Core infrastructure | + +## Quick Start + +```csharp +using Microsoft.Teams.Apps; + +var builder = WebApplication.CreateBuilder(args); +builder.AddTeams(); + +var app = builder.Build(); +var teams = app.UseTeams(); // maps POST /api/messages + +teams.OnMessage(async (context, ct) => +{ + await context.Send($"You said: {context.Activity.Text}"); +}); + +app.Run(); +``` + +## Design Principles + +- **Loose schema** — `CoreActivity` contains only strictly required fields; additional fields are captured via `JsonExtensionData` +- **Simple serialization** — Standard `System.Text.Json` with source generation, no custom converters +- **Extensible schema** — `ChannelData` and entities support extension properties; generics allow custom types +- **MSAL-based auth** — Token acquisition built on Microsoft Identity Web, supporting client secrets, managed identities, and agentic (user-delegated) tokens +- **ASP.NET DI** — All dependencies configured via `IServiceCollection`, reusing the built-in `HttpClient` factory +- **ILogger & IConfiguration** — Standard .NET logging and configuration throughout + +## Configuration + +Create a Teams Application, configure it in Azure Bot Service, and provide credentials via `appsettings.json`: + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "", + "ClientId": "", + "Scope": "https://api.botframework.com/.default", + "ClientCredentials": [ + { + "SourceType": "ClientSecret", + "ClientSecret": "" + } + ] + } +} +``` + +Or via environment variables: + +```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 no MSAL configuration is provided, all communication happens as anonymous REST calls, suitable for local development. + +### Install Playground + +Linux: +```sh +curl -s https://raw.githubusercontent.com/OfficeDev/microsoft-365-agents-toolkit/dev/.github/scripts/install-agentsplayground-linux.sh | bash +``` + +Windows: +```sh +winget install m365agentsplayground +``` + +### Run a Scenario + +```sh +dotnet samples/scenarios/middleware.cs -- --urls "http://localhost:3978" +``` + +## Samples + +| Sample | Description | +|--------|-------------| +| [TeamsBot](samples/TeamsBot/) | Basic Teams bot with message handling | +| [TeamsChannelBot](samples/TeamsChannelBot/) | Channel-scoped messaging | +| [AllInvokesBot](samples/AllInvokesBot/) | Handles all invoke activity types | +| [MessageExtensionBot](samples/MessageExtensionBot/) | Message extension search and actions | +| [MeetingsBot](samples/MeetingsBot/) | Meeting events and participant APIs | +| [OAuthFlowBot](samples/OAuthFlowBot/) | OAuth sign-in and token management | +| [SsoBot](samples/SsoBot/) | Single sign-on (SSO) token exchange | +| [StreamingBot](samples/StreamingBot/) | Progressive streaming responses | +| [Proactive](samples/Proactive/) | Proactive messaging from external triggers | +| [TabApp](samples/TabApp/) | Tab application with backend API | +| [CompatBot](samples/CompatBot/) | Migrating a Bot Framework v4 bot | +| [CoreBot](samples/CoreBot/) | Using Microsoft.Teams.Core directly | + +## Project Structure + +``` +core/ +├── src/ +│ ├── Microsoft.Teams.Core/ # Foundation: protocol, clients, middleware, auth +│ ├── Microsoft.Teams.Apps/ # Framework: routing, handlers, OAuth, API clients +│ └── Microsoft.Teams.Apps.BotBuilder/ # Compat bridge for Bot Framework SDK v4 +├── samples/ # Sample bot applications +└── test/ + ├── Microsoft.Teams.Core.UnitTests/ + ├── Microsoft.Teams.Apps.UnitTests/ + ├── Microsoft.Teams.Apps.BotBuilder.UnitTests/ + └── IntegrationTests/ +``` diff --git a/core/bot_icon.png b/core/bot_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..37c81be7d6b7ee7f51bdcd5770d6992b104e3b9e GIT binary patch literal 1899 zcmV-x2bB1UP)002t}1^@s6I8J)%00006VoOIv0RI60 z0RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_000McNliru;S3rQE*E_6!-xO?2KY%t zK~#9!?b~^9Q$-vH@NZs2Dbf}SX=&OtDWx1O3Iz&+#d1RtsRfa%$Wf46#*1TAJW-mq zfCwJoeLI38qa!jXqXUBnIyn9z0!?~wPNf%ZlaS4O>mQ+I%5L>FeJ>2m{$~2m?ly1V zC;Q&+Z@V>M-Ej&LszMS30!v^CEP*Ak1ePEWSOQC62`qsnumpj?5?BIDUr^vV4vT_0(g^43 zq*jCVGKJ#Aqe%dOZx&dP)w^R*!GeQDD7H+A#i~1^nu-!Sw}W$a5+d5G>q442tO=?y z_AN-j_+G&SlY#-YDW@AA%ILyW)p-?oh`Kwi^~mtbY}@^L2^iZmIAD_AP=(pu@W^l$ z>hFKN=)1WDL{WE_CDjDo3mhOEumcMdFedpH-N7V1q3n}m@K`1bb>gBIDVv-?E8p?w zF8PpD=zxy{{M{leM)lAaOwtQ#GomX~b@HMYeTrHS)fi`LeMl;BLgs)Se87qk zN&10lkL9D-FewJ>MzT<+D!u5vDR8L9I9*GSRLBSWVX+mNI)O=ALp?V!8c&R3p&XT7 z^eS>{cB%3jf*yrV@LqX8F1BKL_YRtaNm@cZlNF8iqfJbevx=anhER=BRzuLE(7^>e zw8VytL~X$&t)QNo5RIqDn7Ff8a-l=F_ikx5L2{7;lyiZ@OKlit(+*710_w@}COkXV z#J>?8p_;ysDuSLx4sP9%Wj5Sy)doyzE7ap-O?ZA>bPMkk{v)DCII8?3j2AyIw_%7i ztj%CjTcFmCG2w*?EY#^5f~11rL0z3hNQ;xmFUu1#I3eshVEHK4jP8p3tZ2?U=*qNy zosP&gEl)V{>xx7SiVq7SmQ3;JsIIV2jA5$I))I6taB^4JJaAC|Zp8mS9y0a+_$bs2 z*OM2$NZHH>`)y?+2F8U20ZXQMctjTz-4(-4Mbtuth8S>oMIvZ^AJC#y{{q(6!tW^* zd*)j}3sOJ}Q?NZJw&k|h{j;-pBm~f*`SU%cp zKdLdR-7+j2i@Dn;Eq%^p4ops8&dw)MFgb;7o1Gc~wJ*iLD!fQ-;gQTs@1zr!dwOel-97HQ&vsboKZ6P%Xw|p?y?e!O_O3;m_5CEVHvP=&jnP}%gg}S zmfa1@GrFh)3zaZ`*ZF2e74<$89;#y78Vy+b<%MQNUYTO;w-xG=aYFI+q6Ghb{WY=g zL_KyLyVNQyLu=2$hZzkhUz-e5cnH(qWnW&v>qn}A|NH9=1bk4%C5D4H&<(95A-Ne*rB@-9Cy$doe0q>?65jN-P!)?TqWT zDkT(q^7v5S7Y7d2rgXY$?mV|N4&)#gOn zMHE==#i(|(U~k=P#{40j5wrx6Q0$s#!PHwLn5rL~E{u7-ZBUwQ(KLAdBdmr4_Rcgj z<_-!17*X)qJPW4X#zOsA;=-uc%GwO2+2&upSAos$KrILCz3FDm8PrKXFrwg-xfa~V zfjU&`#>iK-fzk{M0O~F)u-YBCkSHLc_V4uv4!;CZy zz=(qPbK{Yl#^!cEm%EX%U3(~9u&#nRxL}{oio<>VBLfE`6z|TC$Nd9Xs9#RIF-#{Y z-LNQtTDJ_Fy%_ag7VNXx@wm5NB%rh&>g`!^m^YAxI(o)~q1#Gz%^Y+)>p%h2xD{CQ z9cbWy?aqzIl-t79gAs~1XPU8K5DWF&Sr3N1T%vo5rRymgKbQ=g-2oTpXwesQEQknG z*M)ofM_^%kXQur3vmOlIS`w68RQK~)6hNg*hAqDsF4BA$W$q~BzSnrM_s=?PI9S=N z?ASRE(t`vQ6s*Rmbje7xJK(-D!ZLT1;ZTWN{UH&f0qgfwG=cqb{xSvz5h~ywm9#qch8qZapPXhu=U + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/docs/Activity-Design.md b/core/docs/Activity-Design.md new file mode 100644 index 000000000..759b17668 --- /dev/null +++ b/core/docs/Activity-Design.md @@ -0,0 +1,362 @@ +# CoreActivity / TeamsActivity Design + +## Overview + +The activity model is the central abstraction for all bot communication. It follows a two-layer architecture: + +- **CoreActivity** (`Microsoft.Teams.Core`): Channel-agnostic activity model with typed core properties and dynamic extension properties via `[JsonExtensionData]`. AOT-compatible via source-generated JSON. +- **TeamsActivity** (`Microsoft.Teams.Apps`): Teams-specific extension that shadows base properties with Teams-specific types and promotes additional extension properties into typed fields. + +``` +CoreActivity (Core) — internal constructors, created via CreateBuilder() or deserialization + ├── Declared properties: type, channelId, id, serviceUrl, + │ replyToId, conversation (non-nullable), from, recipient + ├── [JsonExtensionData] Properties bag for everything else (incl. value) + ├── AOT serialization via CoreActivityJsonContext + └── CoreActivityBuilder (fluent builder) + +ConversationAccount (Core) + ├── Declared properties: id, name, isTargeted, + │ agenticAppId, agenticUserId, agenticAppBlueprintId + └── [JsonExtensionData] Properties bag for channel-specific fields + +AgenticIdentity (Core) + ├── AgenticAppId, AgenticUserId, AgenticAppBlueprintId + └── FromAccount(ConversationAccount?) — factory from typed fields + +TeamsActivity (Apps) : CoreActivity + ├── Shadows: From, Recipient (as TeamsConversationAccount) + │ Conversation (as TeamsConversation) + │ — via getter/setter delegates to base slot (single storage) + ├── Additional typed properties: ChannelData, Entities, + │ Timestamp, LocalTimestamp, Locale, LocalTimezone, SuggestedActions + ├── Polymorphic deserialization via ActivityDeserializerMap + ├── Type-specific serialization via ActivitySerializerMap + ├── TeamsActivityBuilder (fluent builder) + └── Derived types: MessageActivity, InvokeActivity, ConversationUpdateActivity, ... + +MessageActivity (Apps) : TeamsActivity + ├── Attachments, Text, TextFormat, AttachmentLayout + └── SuggestedActions extracted from Properties + +InvokeActivity (Apps) : TeamsActivity + ├── Name, Value (JsonNode?) + └── InvokeActivity shadows Value with strongly-typed access + +EventActivity (Apps) : TeamsActivity + ├── Name, Value (JsonNode?) + └── EventActivity shadows Value with strongly-typed access +``` + +## Activity Lifecycle + +``` +Incoming: + HTTP body (JSON) + → CoreActivity.FromJsonStreamAsync() // from, recipient → typed properties + → TurnMiddleware pipeline // channelData, entities, text → Properties bag + → TeamsActivity.FromActivity(coreActivity) // Converts typed + Extract remaining + → ActivityDeserializerMap dispatches to // MessageActivity, InvokeActivity, etc. + concrete type + → Router dispatches to handler + +Outgoing: + Handler builds reply + → TeamsActivityBuilder.WithConversationReference(incoming) + └── WithFrom(incoming.Recipient) // Swaps from/recipient for reply + → ConversationClient.SendActivityAsync(activity) + ├── Reads activity.Recipient.IsTargeted and AgenticIdentity.FromAccount(activity.From) + └── activity.ToJson() serializes for HTTP POST + → POST {serviceUrl}/v3/conversations/{id}/activities/ +``` + +## Core Design Decisions + +### 1. Typed Properties for Protocol-Level Fields + +CoreActivity declares typed `[JsonPropertyName]` properties for fields that are part of the Activity Protocol Specification and needed by the Core layer: + +| Property | Type | Why typed | +|----------|------|-----------| +| `Type` | `string` | Routing decisions | +| `ChannelId` | `string?` | URL construction | +| `Id` | `string?` | Reply targeting | +| `ServiceUrl` | `Uri?` | HTTP endpoint | +| `ReplyToId` | `string?` | Reply threading | +| `Conversation` | `Conversation` (non-nullable) | URL construction, always initialized | +| `From` | `ConversationAccount?` | AgenticIdentity extraction | +| `Recipient` | `ConversationAccount?` | IsTargeted flag for targeted messaging | + +Everything else (`text`, `attachments`, `entities`, `channelData`, `value`, `timestamp`, etc.) remains in the `[JsonExtensionData] Properties` dictionary, promoted to typed properties at the TeamsActivity layer or its derived types (e.g., `value` is promoted by `InvokeActivity` and `EventActivity`). + +### 2. ConversationAccount with Typed Agentic Identity Fields + +`ConversationAccount` declares the agentic identity fields as typed properties rather than relying on the extension data dictionary: + +```csharp +[JsonPropertyName("agenticAppId")] public string? AgenticAppId { get; set; } +[JsonPropertyName("agenticUserId")] public string? AgenticUserId { get; set; } +[JsonPropertyName("agenticAppBlueprintId")] public string? AgenticAppBlueprintId { get; set; } +``` + +The `AgenticIdentity` class is a separate DTO used by `BotRequestOptions` and `BotAuthenticationHandler` for token acquisition. It is constructed from a `ConversationAccount`'s typed fields via `AgenticIdentity.FromAccount(account)` at the point of use — there is no computed property or duplication on `ConversationAccount` itself. + +### 3. Property Shadowing with Getter/Setter Delegates + +TeamsActivity shadows base properties with more specific types using the `new` keyword, but delegates storage to the base slot via getter/setter properties: + +```csharp +// CoreActivity +[JsonPropertyName("from")] public ConversationAccount? From { get; set; } +[JsonPropertyName("conversation")] public Conversation Conversation { get; set; } + +// TeamsActivity — single storage, delegates to base +[JsonPropertyName("from")] +public new TeamsConversationAccount? From +{ + get => base.From as TeamsConversationAccount; + set => base.From = value; +} + +[JsonPropertyName("conversation")] +public new TeamsConversation? Conversation +{ + get => base.Conversation as TeamsConversation; + set => base.Conversation = value!; +} +``` + +Since `TeamsConversationAccount` extends `ConversationAccount` and `TeamsConversation` extends `Conversation`, the derived type is stored directly in the base slot. The getter casts back. This eliminates dual storage and the need for manual sync — code accessing through either a `CoreActivity` or `TeamsActivity` reference sees the same value. + +The `TeamsActivity(CoreActivity)` constructor stores converted types in the base slots: +```csharp +base.From = TeamsConversationAccount.FromConversationAccount(activity.From) ?? new TeamsConversationAccount(); +base.Recipient = TeamsConversationAccount.FromConversationAccount(activity.Recipient) ?? new TeamsConversationAccount(); +base.Conversation = TeamsConversation.FromConversation(activity.Conversation) ?? new TeamsConversation(); +``` + +**Serialization:** The `[JsonPropertyName]` attribute on the `new` property (not `[JsonIgnore]`) ensures the source-generated serializer for TeamsActivity uses the correctly-typed property (e.g., `TeamsConversation?` instead of `Conversation`), preserving fields like `TenantId` and `ConversationType`. + +### 4. Extension Data for Remaining Properties + +`ExtendedPropertiesDictionary.Extract(key)` is used by TeamsActivity subtypes to promote remaining properties from the untyped bag to typed fields: + +```csharp +public T? Extract(string key) +{ + if (!TryGetValue(key, out object? raw)) return default; + Remove(key); // Remove to avoid duplicate serialization + if (raw is T typed) return typed; // Already the right type + if (raw is JsonElement element) // Deserialized from JSON + return JsonSerializer.Deserialize(element.GetRawText()); + return default; // Unknown type — data is lost +} +``` + +This pattern is used for: `channelData`, `entities`, `value`, `attachments`, `text`, `textFormat`, `attachmentLayout`, `suggestedActions`, `name`, `action`, `membersAdded`, `membersRemoved`, `reactionsAdded`, `reactionsRemoved`. + +### 5. Dual Serialization Strategy + +| Path | When | Mechanism | +|------|------|-----------| +| AOT (source-gen) | `CoreActivity.ToJson()` | `CoreActivityJsonContext.Default.CoreActivity` | +| Reflection | `CoreActivity.ToJson(instance)` | `ReflectionJsonOptions` with camelCase | +| Type-specific | `TeamsActivity.ToJson()` | `ActivitySerializerMap` dispatch by runtime type | + +### 6. Builder Pattern + +Both layers provide fluent builders with `With*` (replace) and `Add*` (append) methods: + +- `CoreActivityBuilder` — core-level activities with `WithFrom()`, `WithRecipient()`, `WithConversation()`, `WithProperty()`. Builder parameters accept nullable types where appropriate (`Uri?`, `string?`, `ConversationAccount?`). +- `TeamsActivityBuilder` — Teams-specific, shadows `WithFrom`/`WithRecipient`/`WithConversation` (via `new`) to convert to `TeamsConversationAccount`/`TeamsConversation`. Attachment methods (`WithAttachments`, `AddAttachment`, etc.) set the typed property when the underlying activity is a `MessageActivity`, otherwise store in Properties as fallback. + +`TeamsActivityBuilder.WithConversationReference(activity)` is the canonical way to build a reply — it copies `ServiceUrl`, `ChannelId`, `Conversation` from the incoming activity and swaps `From`/`Recipient`. + +## Serialization Architecture + +### CoreActivity JSON Fields + +``` +Declared properties (deserialized into typed fields): + type, channelId, id, serviceUrl, replyToId, conversation, from, recipient + +Extension properties (deserialized into [JsonExtensionData] Properties): + value, text, textFormat, attachments, entities, channelData, timestamp, + locale, ... (anything not declared above) +``` + +### ConversationAccount JSON Fields + +``` +Declared properties: + id, name, isTargeted, agenticAppId, agenticUserId, agenticAppBlueprintId + +Extension properties: + aadObjectId, userRole, userPrincipalName, givenName, surname, email, + tenantId, ... (anything not declared above) +``` + +### TeamsActivity JSON Fields + +``` +Inherited declared (shadowed with Teams types): + from (TeamsConversationAccount), recipient (TeamsConversationAccount), + conversation (TeamsConversation) + +Inherited declared (used as-is): + type, channelId, id, serviceUrl, replyToId + +Promoted from Properties during construction: + channelData, entities, timestamp, localTimestamp, + locale, localTimezone, suggestedActions + +Promoted by derived types: + MessageActivity: text, textFormat, attachmentLayout, attachments + InvokeActivity: name, value + EventActivity: name, value + ConversationUpdateActivity: membersAdded, membersRemoved + InstallUpdateActivity: action + MessageReactionActivity: reactionsAdded, reactionsRemoved, replyToId + +Remaining extension properties (via [JsonExtensionData]): + Any fields not declared or promoted above +``` + +### Source-Generated JSON Contexts + +| Context | Project | Types | +|---------|---------|-------| +| `CoreActivityJsonContext` | Core | CoreActivity, ChannelData, Conversation, ConversationAccount, ExtendedPropertiesDictionary, primitives | +| `TeamsActivityJsonContext` | Apps | TeamsActivity, MessageActivity, StreamingActivity, all Entity types, SuggestedActions, TeamsAttachment, TeamsConversation, TeamsConversationAccount, TeamsChannelData | + +## Class Hierarchy + +``` +CoreActivity +└── TeamsActivity + ├── MessageActivity + ├── StreamingActivity + ├── InvokeActivity + │ └── InvokeActivity + ├── ConversationUpdateActivity + ├── EventActivity + │ └── EventActivity + ├── InstallUpdateActivity + ├── MessageReactionActivity + ├── MessageUpdateActivity + └── MessageDeleteActivity + +Conversation +└── TeamsConversation + +ConversationAccount +└── TeamsConversationAccount + +CoreActivityBuilder +├── CoreActivityBuilder +└── TeamsActivityBuilder +``` + +## Property Flow (Incoming Activity) + +``` +JSON → CoreActivity deserialization + │ + │ Typed properties populated directly by JSON deserializer: + │ type, channelId, id, serviceUrl, replyToId, conversation, from, recipient + │ + │ Remaining fields go to [JsonExtensionData] Properties: + │ value, channelData, entities, attachments, text, textFormat, timestamp, ... + │ + ├── TeamsActivity(CoreActivity) constructor: + │ From base typed properties (converted, stored in base slot): + │ base.From ← TeamsConversationAccount.FromConversationAccount(activity.From) + │ base.Recipient ← TeamsConversationAccount.FromConversationAccount(activity.Recipient) + │ base.Conversation ← TeamsConversation.FromConversation(activity.Conversation) + │ From Properties via Extract: + │ ChannelData ← Extract("channelData") + │ Entities ← Extract("entities") + │ + ├── MessageActivity(CoreActivity) constructor: + │ Attachments ← Extract>("attachments") + │ Text ← Extract("text") + │ TextFormat ← Extract("textFormat") + │ AttachmentLayout ← Extract("attachmentLayout") + │ SuggestedActions ← Extract("suggestedActions") + │ + ├── InvokeActivity: Name ← Extract("name") + │ Value ← Extract("value") + ├── EventActivity: Name ← Extract("name") + │ Value ← Extract("value") + ├── InstallUpdateActivity: Action ← Extract("action") + ├── ConversationUpdateActivity: + │ MembersAdded ← Extract>("membersAdded") + │ MembersRemoved ← Extract>("membersRemoved") + └── MessageReactionActivity: + ReactionsAdded ← Extract>("reactionsAdded") + ReactionsRemoved ← Extract>("reactionsRemoved") + ReplyToId ← Extract("replyToId") +``` + +## Agentic Identity Flow + +``` +Incoming activity JSON: + { "from": { "id": "bot1", "agenticAppId": "app-123", "agenticUserId": "user-456" } } + +CoreActivity.FromJsonStreamAsync() + → activity.From = ConversationAccount { Id="bot1", AgenticAppId="app-123", AgenticUserId="user-456" } + +ConversationClient.SendActivityAsync(activity): + 1. AgenticIdentity.FromAccount(activity.From) + → Reads AgenticAppId, AgenticUserId, AgenticAppBlueprintId from typed fields + → Returns AgenticIdentity { AgenticAppId="app-123", AgenticUserId="user-456" } + 2. CreateRequestOptions(agenticIdentity, ...) + → BotRequestOptions.AgenticIdentity = agenticIdentity + 3. BotHttpClient.SendAsync(...) + → request.Options.Set(AgenticIdentityKey, agenticIdentity) + 4. BotAuthenticationHandler middleware + → Uses AgenticIdentity for user-delegated token acquisition +``` + +## Remaining Considerations + +### Shared Mutable Properties Dictionary (Shallow Copy) + +**Files: CoreActivity.cs, TeamsConversationAccount.cs** + +The copy constructor shares the Properties reference: + +```csharp +Properties = activity.Properties; // Reference copy, not deep copy +``` + +When `TeamsActivity(CoreActivity)` calls `Extract<>()`, it removes keys from the shared dictionary, mutating the source activity. This is currently safe because the source isn't used after conversion, but it's fragile. Consider a shallow clone or document the contract that the source is consumed. + +### Extract Silently Loses Data for Unknown Types + +When `raw` is neither `T` nor `JsonElement`, `Extract` removes the key and returns `default`. This only affects Properties-based fields (channelData, attachments, entities, etc.) since `from`/`recipient`/`conversation` are now typed properties and never go through Extract. + +### Context.SendActivityAsync Overwrites Conversation Reference + +`Context.SendActivityAsync(TeamsActivity)` always applies `WithConversationReference(Activity)`, which overwrites `ServiceUrl`, `ChannelId`, `Conversation`, and `From`. For cross-conversation or proactive messaging, use `TeamsBotApplication.SendActivityAsync` directly. + +### CoreActivity Constructors are Internal + +CoreActivity constructors are `internal` — external consumers create instances via `CoreActivity.CreateBuilder()` or JSON deserialization (`FromJsonString`, `FromJsonStreamAsync`). The single `[JsonConstructor]` parameterized constructor handles both direct construction and deserialization, defaulting to `ActivityType.Message` and initializing `Conversation` to a non-null empty instance. + +## Test Coverage + +| Area | Coverage | +|------|----------| +| ConversationClient URL construction | Good | +| ConversationClient isTargeted from Recipient property | Good | +| ConversationClient AgenticIdentity from From property | Good | +| CoreActivity JSON round-trip (from/recipient as typed props) | Good | +| TeamsActivity.FromActivity() conversion | Good | +| TeamsActivity.ToJson() single from/recipient in output | Good | +| AgenticIdentity.FromAccount factory | Good | +| Extract with JsonElement (for channelData, entities, etc.) | Good | +| TeamsActivityBuilder getter/setter property access (From/Recipient) | Good | +| TeamsActivityBuilder.WithConversationReference | Partial | +| Context.SendActivityAsync conversation ref application | Missing | diff --git a/core/docs/ApiClient-Design.md b/core/docs/ApiClient-Design.md new file mode 100644 index 000000000..c04178c4b --- /dev/null +++ b/core/docs/ApiClient-Design.md @@ -0,0 +1,249 @@ +# ApiClient Design Document + +## Overview + +The `ApiClient` class (`Microsoft.Teams.Apps.Api.Clients`) provides a hierarchical, Libraries-compatible API surface for Teams Bot operations. It organizes Bot Framework v3 REST API calls into sub-clients that delegate to the core SDK infrastructure rather than making raw HTTP calls. + +## Architecture + +``` +ApiClient (top-level facade) +├── Bots → BotClient +│ └── SignIn → BotSignInClient [delegates to core UserTokenClient] +├── Conversations → ConversationApiClient [delegates to core ConversationClient] +│ ├── Activities → ActivityClient +│ ├── Members → MemberClient +│ └── Reactions → ReactionClient [Experimental] +├── Users → UserClient +│ └── Token → UserTokenApiClient [delegates to core UserTokenClient] +├── Teams → TeamClient [BotHttpClient → serviceUrl/v3/teams/] +└── Meetings → MeetingClient [BotHttpClient → serviceUrl/v1/meetings/] +``` + +### HTTP strategies + +| Sub-client | Strategy | Why | +|---|---|---| +| Conversations (Activities, Members, Reactions) | Delegates to core `ConversationClient` | Reuses auth, logging, agents-channel handling, agentic identity support | +| Bots.SignIn, Users.Token | Delegates to core `UserTokenClient` | Reuses auth, logging, agentic identity; single source of truth for token API calls | +| Teams, Meetings | Uses `BotHttpClient` directly | No core client equivalent exists for these endpoints | + +### Experimental APIs + +| Feature | Diagnostic ID | Notes | +|---|---|---| +| `ReactionClient` | `ExperimentalTeamsReactions` | Reactions endpoint assumed but not confirmed in Teams Bot Framework API | +| `ActivityClient.CreateTargetedAsync` / `UpdateTargetedAsync` / `DeleteTargetedAsync` | `ExperimentalTeamsTargeted` | Targeted (recipient-only visible) activities; not supported in team channel conversations | + +## Construction & Scoping + +### The serviceUrl problem + +The Bot Framework service URL is per-request (comes from `activity.ServiceUrl`), but `ApiClient` is per-application (DI singleton). The `ApiClient` solves this with a two-step pattern: + +1. **DI registration** creates a base `ApiClient` without a serviceUrl +2. **Per-request**, `ForServiceUrl(uri)` creates a lightweight scoped copy with all sub-clients bound + +### DI-friendly constructor (no serviceUrl) + +```csharp +// Registered automatically by AddTeamsBotApplication() +// The [ActivatorUtilitiesConstructor] attribute tells DI to prefer this constructor +[ActivatorUtilitiesConstructor] +public ApiClient(HttpClient httpClient, ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger? logger = null) +``` + +`AddTeamsBotApplication()` calls `AddBotClient(...)` which registers `ApiClient` as a typed HTTP client with `BotAuthenticationHandler`. The `ConversationClient` and `UserTokenClient` dependencies are resolved from DI automatically. + +**Important:** The base `ApiClient` has `Conversations`, `Teams`, and `Meetings` set to `null!`. Only `Bots` and `Users` are available on the unscoped instance. Accessing `Conversations`, `Teams`, or `Meetings` directly causes `NullReferenceException`. Always use `ForServiceUrl()` or `Context.Api` to get a scoped instance. + +### Per-request scoping via Context.Api + +In activity handlers, use the `Context.Api` property which auto-scopes to the current activity's service URL: + +```csharp +// In a handler — Context.Api is lazy-initialized via ForServiceUrl(Activity.ServiceUrl) +botApp.OnMessage(async (ctx, ct) => +{ + var members = await ctx.Api.Conversations.Members.GetAsync(conversationId, ct); + var team = await ctx.Api.Teams.GetByIdAsync(teamId, ct); +}); +``` + +**Do NOT use `ctx.TeamsBotApplication.Api.Conversations`** — that is the unscoped base client and will throw `NullReferenceException`. + +### ForServiceUrl (explicit scoping) + +For code outside handlers (e.g., proactive messaging, compat layer): + +```csharp +ApiClient scoped = baseApiClient.ForServiceUrl(activity.ServiceUrl); +await scoped.Conversations.Activities.CreateAsync(conversationId, activity); +``` + +`ForServiceUrl` shares the underlying `BotHttpClient`, `ConversationClient`, and `UserTokenClient` — only the sub-client wrappers are new allocations. + +### Constructors + +| Constructor | Use case | +|---|---| +| `ApiClient(HttpClient, ConversationClient, UserTokenClient, ILogger?)` | DI registration (marked `[ActivatorUtilitiesConstructor]`) | +| `ApiClient(Uri, HttpClient, ConversationClient, UserTokenClient, ILogger?)` | Fully initialized with known serviceUrl | +| `ApiClient(ApiClient)` | Copy constructor | +| Private: `ApiClient(BotHttpClient, ConversationClient, UserTokenClient, Uri)` | Used by `ForServiceUrl` — shares clients | + +## Delegation Pattern + +The Apps-layer sub-clients delegate to core clients rather than duplicating HTTP logic: + +- **Conversation sub-clients** (`ActivityClient`, `MemberClient`, `ReactionClient`) → core `ConversationClient` +- **Token/SignIn sub-clients** (`UserTokenApiClient`, `BotSignInClient`) → core `UserTokenClient` + +This ensures: + +- Single source of truth for URL construction, auth, and error handling +- Agents-channel ID truncation logic is preserved +- Agentic identity support works transparently for all operations +- Custom headers and logging from core clients apply + +### Parameter bridging + +The Libraries-style API takes `(conversationId, activity)` as separate parameters, while the core `ConversationClient` expects context embedded in the activity or passed as method parameters. The sub-clients bridge this: + +``` +ActivityClient.CreateAsync(conversationId, activity) + → sets activity.ServiceUrl, activity.Conversation + → calls ConversationClient.SendActivityAsync(activity) + +MemberClient.GetAsync(conversationId) + → calls ConversationClient.GetConversationMembersAsync(conversationId, serviceUrl) + +ReactionClient.AddAsync(conversationId, activityId, reactionType) + → calls ConversationClient.AddReactionAsync(conversationId, activityId, reactionType, serviceUrl) +``` + +### Method mapping + +#### ActivityClient → ConversationClient + +| ActivityClient | ConversationClient | Notes | +|---|---|---| +| `CreateAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `ServiceUrl` and `Conversation` on activity | +| `UpdateAsync(conversationId, id, activity)` | `UpdateActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` on activity | +| `ReplyAsync(conversationId, id, activity)` | `SendActivityAsync(activity)` | Sets `ReplyToId`, `ServiceUrl`, `Conversation` | +| `DeleteAsync(conversationId, id)` | `DeleteActivityAsync(conversationId, id, serviceUrl)` | | +| `CreateTargetedAsync(conversationId, activity)` | `SendActivityAsync(activity)` | Sets `Recipient.IsTargeted = true` [Experimental] | +| `UpdateTargetedAsync(conversationId, id, activity)` | `UpdateTargetedActivityAsync(conversationId, id, activity)` | Sets `ServiceUrl` [Experimental] | +| `DeleteTargetedAsync(conversationId, id)` | `DeleteTargetedActivityAsync(conversationId, id, serviceUrl)` | [Experimental] | + +#### MemberClient → ConversationClient + +| MemberClient | ConversationClient | +|---|---| +| `GetAsync(conversationId)` | `GetConversationMembersAsync(conversationId, serviceUrl)` | +| `GetByIdAsync(conversationId, memberId)` | `GetConversationMemberAsync(conversationId, memberId, serviceUrl)` | +| `GetByIdAsync(conversationId, memberId)` | `GetConversationMemberAsync(conversationId, memberId, serviceUrl)` | +| `DeleteAsync(conversationId, memberId)` | `DeleteConversationMemberAsync(conversationId, memberId, serviceUrl)` | + +#### ReactionClient → ConversationClient [Experimental] + +| ReactionClient | ConversationClient | +|---|---| +| `AddAsync(conversationId, activityId, reactionType)` | `AddReactionAsync(conversationId, activityId, reactionType, serviceUrl)` | +| `DeleteAsync(conversationId, activityId, reactionType)` | `DeleteReactionAsync(conversationId, activityId, reactionType, serviceUrl)` | + +#### ConversationApiClient → ConversationClient + +| ConversationApiClient | ConversationClient | +|---|---| +| `CreateAsync(parameters)` | `CreateConversationAsync(parameters, serviceUrl)` | + +#### TeamClient (direct HTTP) + +| TeamClient | Endpoint | +|---|---| +| `GetByIdAsync(id)` | `GET {serviceUrl}/v3/teams/{id}` | +| `GetConversationsAsync(id)` | `GET {serviceUrl}/v3/teams/{id}/conversations` | + +#### MeetingClient (direct HTTP) + +| MeetingClient | Endpoint | +|---|---| +| `GetByIdAsync(id)` | `GET {serviceUrl}/v1/meetings/{id}` | +| `GetParticipantAsync(meetingId, id, tenantId)` | `GET {serviceUrl}/v1/meetings/{meetingId}/participants/{id}?tenantId={tenantId}` | + +#### BotSignInClient → UserTokenClient + +| BotSignInClient | UserTokenClient | +|---|---| +| `GetUrlAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | `GetSignInUrlAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | +| `GetResourceAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | `GetSignInResourceAsync(state, codeChallenge?, emulatorUrl?, finalRedirect?)` | + +#### UserTokenApiClient → UserTokenClient + +| UserTokenApiClient | UserTokenClient | Notes | +|---|---|---| +| `GetAsync(userId, connectionName, channelId, code?)` | `GetTokenAsync(userId, connectionName, channelId, code?)` | | +| `GetAadAsync(userId, connectionName, channelId, resourceUrls?)` | `GetAadTokensAsync(userId, connectionName, channelId, resourceUrls?)` | `IList?` → `string[]?` | +| `GetStatusAsync(userId, channelId, includeFilter?)` | `GetTokenStatusAsync(userId, channelId, include?)` | Returns `GetTokenStatusResult[]` as `IList<>?` | +| `SignOutAsync(userId, connectionName, channelId)` | `SignOutUserAsync(userId, connectionName?, channelId?)` | | +| `ExchangeAsync(userId, connectionName, channelId, token)` | `ExchangeTokenAsync(userId, connectionName, channelId, token?)` | | + +## File Layout + +``` +core/src/Microsoft.Teams.Apps/Api/Clients/ +├── ApiClient.cs Top-level facade, DI entry point, ForServiceUrl factory +├── ConversationApiClient.cs Conversation facade → delegates to core ConversationClient +├── ActivityClient.cs Activity CRUD + targeted → delegates to core ConversationClient +├── MemberClient.cs Member operations → delegates to core ConversationClient +├── ReactionClient.cs Reaction operations → delegates to core ConversationClient [Experimental] +├── TeamClient.cs Team info → BotHttpClient (v3/teams/) +├── MeetingClient.cs Meeting info → BotHttpClient (v1/meetings/) + models +├── BotClient.cs Bot facade (groups SignIn) +├── BotSignInClient.cs Sign-in URLs → delegates to core UserTokenClient +├── BotTokenClient.cs Static scope constants +├── UserClient.cs User facade (groups Token) +└── UserTokenApiClient.cs User token ops → delegates to core UserTokenClient +``` + +## Integration with Context and Handlers + +The `Context` class exposes a lazy `Api` property: + +```csharp +public ApiClient Api => _api ??= TeamsBotApplication.Api.ForServiceUrl(Activity.ServiceUrl); +``` + +This is the primary way handlers should access the API clients. It ensures the scoped `ApiClient` is created once per request and reused across multiple calls within the same handler. + +## Integration with TeamsApiClient + +`TeamsApiClient` retrieves clients from `TurnState`: + +- **`ApiClient`** (from `TurnState.Get()`) for Teams/Meetings operations: + - `client.Teams.GetByIdAsync(teamId)` — team details + - `client.Teams.GetConversationsAsync(teamId)` — channel list + - `client.Meetings.GetParticipantAsync(meetingId, participantId, tenantId)` — meeting participant + +- **`ConversationClient`** (from `CompatConnectorClient` in `TurnState.Get()`) for member operations: + - `GetConversationMemberAsync(...)` — single member + - `GetConversationMembersAsync(...)` — all members + - `GetConversationPagedMembersAsync(...)` — paged members + +**Note on TeamsBotFrameworkHttpAdapter scoping:** The `TeamsBotFrameworkHttpAdapter` currently stores the unscoped `TeamsApiClient` in `TurnState` (line 59). This works because `TeamsApiClient` uses the `ApiClient` sub-clients which are scoped. However, `TeamsBotFrameworkHttpAdapter` should ideally scope the `ApiClient` before storing: + +```csharp +// Current (unscoped — Teams/Meetings sub-clients are null): +turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); + +// Should be (scoped): +ApiClient scopedClient = _teamsBotApplication.TeamsApiClient.ForServiceUrl(new Uri(activity.ServiceUrl)); +turnContext.TurnState.Add(scopedClient); +``` + +## Future Work + +- **BatchClient**: Batch messaging operations (`SendMessageToListOfUsersAsync`, etc.) need a new sub-client on `ApiClient` using `BotHttpClient` for the `v3/batch/conversation/` endpoints. +- **MeetingClient.SendMeetingNotificationAsync**: Meeting notification support needs to be added along with notification model types. +- **TeamsBotFrameworkHttpAdapter scoping**: Fix `TeamsBotFrameworkHttpAdapter` to call `ForServiceUrl` before storing `ApiClient` in `TurnState`. diff --git a/core/docs/Architecture.md b/core/docs/Architecture.md new file mode 100644 index 000000000..066503728 --- /dev/null +++ b/core/docs/Architecture.md @@ -0,0 +1,938 @@ +# Teams Bot SDK Architecture Documentation + +## Overview + +The Teams Bot SDK consists of three layered projects that provide a modern, efficient, and backward-compatible framework for building Microsoft Teams bots. + +```mermaid +graph TB + subgraph "Application Layer" + UserBot[User Bot Application] + end + + subgraph "SDK Layers" + Compat[Microsoft.Teams.Apps.BotBuilder
Bot Framework v4 Compatibility] + Apps[Microsoft.Teams.Apps
Teams-Specific Features] + Core[Microsoft.Teams.Core
Core Bot Infrastructure] + end + + subgraph "External Dependencies" + BotFramework[Bot Framework v4 SDK] + TeamsServices[Microsoft Teams Services] + end + + UserBot --> Compat + UserBot --> Apps + Compat --> Apps + Compat --> BotFramework + Apps --> Core + Core --> TeamsServices + + style Core fill:#e1f5ff + style Apps fill:#fff4e1 + style Compat fill:#ffe1f5 +``` + +--- + +## 1. Microsoft.Teams.Core + +**Purpose**: Provides the foundational infrastructure for building Teams bots with a clean, modern API focused on performance and System.Text.Json serialization. + +### Architecture Overview + +```mermaid +graph TB + subgraph "Core Components" + BotApp[BotApplication] + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + HttpClient[BotHttpClient] + end + + subgraph "Schema Layer" + CoreActivity[CoreActivity] + AgenticId[AgenticIdentity] + ConvAccount[ConversationAccount] + JsonContext[CoreActivityJsonContext] + end + + subgraph "Middleware Pipeline" + TurnMW[TurnMiddleware] + CustomMW[ITurnMiddleware] + end + + subgraph "Hosting" + AuthHandler[BotAuthenticationHandler] + Extensions[AddBotApplicationExtensions] + Config[BotConfig] + end + + BotApp --> TurnMW + BotApp --> ConvClient + BotApp --> TokenClient + ConvClient --> HttpClient + TokenClient --> HttpClient + TurnMW --> CustomMW + BotApp --> CoreActivity + CoreActivity --> JsonContext + + style BotApp fill:#4a90e2 + style CoreActivity fill:#7ed321 + style TurnMW fill:#f5a623 +``` + +### Core Patterns + +#### 1. **Middleware Pipeline Pattern** + +The middleware pipeline allows processing activities through a chain of handlers. + +```mermaid +sequenceDiagram + participant HTTP as HTTP Request + participant BotApp as BotApplication + participant Pipeline as TurnMiddleware + participant MW1 as Middleware 1 + participant MW2 as Middleware 2 + participant Handler as OnActivity Handler + + HTTP->>BotApp: ProcessAsync(HttpContext) + BotApp->>BotApp: Deserialize CoreActivity + BotApp->>Pipeline: RunPipelineAsync(activity) + Pipeline->>MW1: OnTurnAsync(activity, next) + MW1->>Pipeline: next(activity) + Pipeline->>MW2: OnTurnAsync(activity, next) + MW2->>Pipeline: next(activity) + Pipeline->>Handler: Invoke(activity) + Handler-->>Pipeline: Complete + Pipeline-->>BotApp: Complete + BotApp-->>HTTP: Response +``` + +**Key Classes**: +- `TurnMiddleware`: Manages the middleware pipeline execution +- `ITurnMiddleware`: Interface for custom middleware components +- `BotApplication`: Orchestrates activity processing + +#### 2. **Client Pattern** + +Separate clients handle different aspects of bot communication. + +```mermaid +graph LR + subgraph "Client Layer" + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + end + + subgraph "HTTP Layer" + BotHttpClient[BotHttpClient] + RequestOpts[BotRequestOptions] + end + + subgraph "Services" + ConvAPI["/v3/conversations"] + TokenAPI["/api/usertoken"] + end + + ConvClient --> BotHttpClient + TokenClient --> BotHttpClient + BotHttpClient --> RequestOpts + BotHttpClient --> ConvAPI + BotHttpClient --> TokenAPI + + style ConvClient fill:#4a90e2 + style TokenClient fill:#4a90e2 +``` + +**Key Features**: +- `ConversationClient`: Manages conversation operations (send, reply, get members) +- `UserTokenClient`: Handles OAuth token operations +- `BotHttpClient`: Centralized HTTP client with authentication and retry logic +- `BotRequestOptions`: Configures requests with authentication and custom headers + +#### 3. **Schema with Source Generation** + +Uses System.Text.Json source generators for optimal performance. + +```csharp +[JsonSerializable(typeof(CoreActivity))] +[JsonSerializable(typeof(ConversationAccount))] +internal partial class CoreActivityJsonContext : JsonSerializerContext +{ +} +``` + +**Benefits**: +- Zero-allocation JSON serialization +- AOT (Ahead-of-Time) compilation support +- Faster startup time +- Smaller deployment size + +### Key Components + +| Component | Purpose | Pattern | +|-----------|---------|---------| +| `BotApplication` | Main entry point for processing activities | Facade | +| `ConversationClient` | Manages conversation operations | Client | +| `UserTokenClient` | Handles user authentication tokens | Client | +| `BotHttpClient` | Centralized HTTP communication | Client | +| `TurnMiddleware` | Executes middleware pipeline | Chain of Responsibility | +| `CoreActivity` | Activity model with source generation | DTO | +| `AgenticIdentity` | Authentication identity for API calls | DTO | +| `BotAuthenticationHandler` | JWT authentication for ASP.NET Core | Authentication Handler | + +### Configuration + +```csharp +services.AddBotApplication(configuration); +// Registers: +// - BotApplication (Singleton) +// - ConversationClient (Singleton) +// - UserTokenClient (Singleton) +// - BotHttpClient (Singleton) +// - Authentication handlers +``` + +--- + +## 2. Microsoft.Teams.Apps + +**Purpose**: Extends Core with Teams-specific features, handlers, and the TeamsApiClient for advanced Teams operations. + +### Architecture Overview + +```mermaid +graph TB + subgraph "Application Layer" + TeamsBotApp[TeamsBotApplication] + Builder[TeamsBotApplicationBuilder] + end + + subgraph "Handler System" + MsgHandler[MessageHandler] + ConvHandler[ConversationUpdateHandler] + InvokeHandler[InvokeHandler] + InstallHandler[InstallationUpdateHandler] + ReactionHandler[MessageReactionHandler] + end + + subgraph "Teams API Client" + TeamsAPI[TeamsApiClient] + MeetingOps[Meeting Operations] + TeamOps[Team Operations] + BatchOps[Batch Operations] + end + + subgraph "Schema Layer" + TeamsActivity[TeamsActivity] + TeamsChannelData[TeamsChannelData] + TeamsAttachment[TeamsAttachment] + Entities[Entity Types] + end + + subgraph "Context" + Context[Context] + end + + TeamsBotApp --> TeamsAPI + TeamsBotApp --> MsgHandler + TeamsBotApp --> ConvHandler + TeamsBotApp --> InvokeHandler + TeamsBotApp --> InstallHandler + TeamsBotApp --> ReactionHandler + + MsgHandler --> Context + ConvHandler --> Context + InvokeHandler --> Context + + TeamsAPI --> MeetingOps + TeamsAPI --> TeamOps + TeamsAPI --> BatchOps + + TeamsBotApp --> TeamsActivity + TeamsActivity --> TeamsChannelData + + style TeamsBotApp fill:#5856d6 + style TeamsAPI fill:#ff9500 + style Context fill:#34c759 +``` + +### Core Patterns + +#### 1. **Handler Pattern with Typed Arguments** + +Teams-specific activities are routed to typed handlers. + +```mermaid +sequenceDiagram + participant Core as BotApplication + participant Teams as TeamsBotApplication + participant Handler as MessageHandler + participant UserCode as User Handler + + Core->>Teams: OnActivity(CoreActivity) + Teams->>Teams: Convert to TeamsActivity + Teams->>Teams: Create Context + Teams->>Handler: Invoke(MessageArgs, Context) + Handler->>UserCode: Execute(args, context) + UserCode-->>Handler: Complete + Handler-->>Teams: Complete + Teams-->>Core: Complete +``` + +**Handler Types**: +```csharp +public delegate Task MessageHandler(MessageArgs args, Context context, CancellationToken ct); +public delegate Task ConversationUpdateHandler(ConversationUpdateArgs args, Context context, CancellationToken ct); +public delegate Task InvokeHandler(Context context, CancellationToken ct); +public delegate Task InstallationUpdateHandler(InstallationUpdateArgs args, Context context, CancellationToken ct); +public delegate Task MessageReactionHandler(MessageReactionArgs args, Context context, CancellationToken ct); +``` + +#### 2. **Builder Pattern for Application Configuration** + +Fluent API for configuring Teams bot applications. + +```mermaid +graph LR + Start[TeamsBotApplicationBuilder] --> OnMsg[OnMessage] + OnMsg --> OnConv[OnConversationUpdate] + OnConv --> OnInvoke[OnInvoke] + OnInvoke --> OnInstall[OnInstallationUpdate] + OnInstall --> OnReact[OnMessageReaction] + OnReact --> Build[Build] + Build --> App[TeamsBotApplication] + + style Start fill:#5856d6 + style App fill:#5856d6 +``` + +**Usage**: +```csharp +var builder = new TeamsBotApplicationBuilder() + .OnMessage(async (args, context, ct) => { + await context.SendActivityAsync("Hello!"); + }) + .OnConversationUpdate(async (args, context, ct) => { + // Handle member added/removed + }) + .OnInvoke(async (context, ct) => { + return new CoreInvokeResponse { Status = 200 }; + }); + +var app = builder.Build(services); +``` + +#### 3. **Context Pattern** + +Provides a rich context object for bot operations. + +```mermaid +graph TB + Context[Context] + + Context --> Activity[TeamsActivity] + Context --> BotApp[TeamsBotApplication] + Context --> Conv[ConversationClient] + Context --> Token[UserTokenClient] + Context --> Teams[TeamsApiClient] + + Context --> Send[SendActivityAsync] + Context --> Reply[ReplyAsync] + Context --> Update[UpdateActivityAsync] + Context --> Delete[DeleteActivityAsync] + + style Context fill:#34c759 +``` + +**Key Features**: +- Encapsulates current activity and bot application +- Provides convenience methods for common operations +- Access to all clients (Conversation, Token, Teams) +- Simplified response methods + +#### 4. **Teams API Client Pattern** + +Specialized client for Teams-specific operations. + +```mermaid +graph TB + subgraph "TeamsApiClient" + Client[TeamsApiClient] + end + + subgraph "Meeting Operations" + FetchMeeting[FetchMeetingInfoAsync] + FetchParticipant[FetchParticipantAsync] + SendNotification[SendMeetingNotificationAsync] + end + + subgraph "Team Operations" + FetchTeam[FetchTeamDetailsAsync] + FetchChannels[FetchChannelListAsync] + end + + subgraph "Batch Operations" + SendToUsers[SendMessageToListOfUsersAsync] + SendToChannels[SendMessageToListOfChannelsAsync] + SendToTeam[SendMessageToAllUsersInTeamAsync] + SendToTenant[SendMessageToAllUsersInTenantAsync] + GetOpState[GetOperationStateAsync] + GetFailed[GetPagedFailedEntriesAsync] + Cancel[CancelOperationAsync] + end + + Client --> FetchMeeting + Client --> FetchParticipant + Client --> SendNotification + Client --> FetchTeam + Client --> FetchChannels + Client --> SendToUsers + Client --> SendToChannels + Client --> SendToTeam + Client --> SendToTenant + Client --> GetOpState + Client --> GetFailed + Client --> Cancel + + style Client fill:#ff9500 +``` + +### Key Components + +| Component | Purpose | Pattern | +|-----------|---------|---------| +| `TeamsBotApplication` | Teams-specific bot application | Specialization | +| `TeamsBotApplicationBuilder` | Fluent configuration API | Builder | +| `TeamsApiClient` | Teams-specific API operations | Client | +| `Context` | Rich context for handlers | Context Object | +| `TeamsActivity` | Teams-enhanced activity model | DTO | +| `MessageHandler` | Delegate for message handling | Handler | +| `ConversationUpdateHandler` | Delegate for conversation updates | Handler | +| `InvokeHandler` | Delegate for invoke activities | Handler | +| `TeamsChannelData` | Teams-specific channel data | DTO | +| `Entity` | Base class for activity entities | DTO | + +### REST API Endpoints + +| Operation | Endpoint | Description | +|-----------|----------|-------------| +| Meeting Info | `GET /v1/meetings/{meetingId}` | Get meeting details | +| Participant | `GET /v1/meetings/{meetingId}/participants/{participantId}` | Get participant info | +| Notification | `POST /v1/meetings/{meetingId}/notification` | Send in-meeting notification | +| Team Details | `GET /v3/teams/{teamId}` | Get team information | +| Channels | `GET /v3/teams/{teamId}/channels` | List team channels | +| Batch Users | `POST /v3/batch/conversation/users/` | Message multiple users | +| Batch Channels | `POST /v3/batch/conversation/channels/` | Message multiple channels | +| Batch Team | `POST /v3/batch/conversation/team/` | Message all team members | +| Batch Tenant | `POST /v3/batch/conversation/tenant/` | Message entire tenant | +| Operation State | `GET /v3/batch/conversation/{operationId}` | Get batch operation status | +| Failed Entries | `GET /v3/batch/conversation/failedentries/{operationId}` | Get failed batch entries | +| Cancel Operation | `DELETE /v3/batch/conversation/{operationId}` | Cancel batch operation | + +### Configuration + +```csharp +services.AddTeamsBotApplication(configuration); +// Registers everything from Core plus: +// - TeamsBotApplication (Singleton) +// - TeamsApiClient (Singleton) +// - IHttpContextAccessor +``` + +--- + +## 3. Microsoft.Teams.Apps.BotBuilder + +**Purpose**: Provides backward compatibility with Bot Framework v4 SDK, allowing existing bots to migrate incrementally to the new Teams SDK. + +### Architecture Overview + +```mermaid +graph TB + subgraph "Compatibility Layer" + TeamsBotFrameworkHttpAdapter[TeamsBotFrameworkHttpAdapter] + TeamsBotAdapter[TeamsBotAdapter] + CompatMiddleware[CompatAdapterMiddleware] + end + + subgraph "Client Adapters" + CompatConnector[CompatConnectorClient] + CompatConversations[CompatConversations] + CompatUserToken[CompatUserTokenClient] + end + + subgraph "Static Helpers" + TeamsApiClient[TeamsApiClient] + ActivitySchemaMapper[ActivitySchemaMapper Extensions] + end + + subgraph "Bot Framework v4" + BFAdapter[IBotFrameworkHttpAdapter] + BFBot[IBot] + BFMiddleware[IMiddleware] + TurnContext[ITurnContext] + end + + subgraph "Teams SDK" + TeamsBotApp[TeamsBotApplication] + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + TeamsAPI[TeamsApiClient] + end + + TeamsBotFrameworkHttpAdapter -.implements.-> BFAdapter + CompatMiddleware -.implements.-> ITurnMiddleware + + TeamsBotFrameworkHttpAdapter --> TeamsBotApp + TeamsBotFrameworkHttpAdapter --> TeamsBotAdapter + TeamsBotFrameworkHttpAdapter --> CompatMiddleware + + CompatConnector --> CompatConversations + CompatConversations --> ConvClient + CompatUserToken --> TokenClient + + TeamsApiClient --> ConvClient + TeamsApiClient --> TeamsAPI + TeamsApiClient --> ActivitySchemaMapper + + BFBot --> TurnContext + TurnContext --> CompatConnector + + style TeamsBotFrameworkHttpAdapter fill:#ff2d55 + style TeamsApiClient fill:#ff2d55 +``` + +### Core Patterns + +#### 1. **Adapter Pattern** + +Bridges Bot Framework v4 interfaces to Teams SDK implementations. + +```mermaid +sequenceDiagram + participant BF as Bot Framework Bot (IBot) + participant Adapter as TeamsBotFrameworkHttpAdapter + participant Core as TeamsBotApplication + participant Handler as User Handler + + BF->>Adapter: ProcessAsync(request, response) + Adapter->>Adapter: Register OnActivity handler + Adapter->>Core: ProcessAsync(HttpContext) + Core->>Core: Process CoreActivity + Core->>Adapter: OnActivity callback + Adapter->>Adapter: Convert CoreActivity to Activity + Adapter->>Adapter: Create TurnContext + Adapter->>Adapter: Add clients to TurnState + Adapter->>BF: bot.OnTurnAsync(turnContext) + BF->>Handler: User code executes + Handler-->>BF: Complete + BF-->>Adapter: Complete + Adapter-->>Core: Complete +``` + +**Key Adaptations**: +- `IBotFrameworkHttpAdapter` → `TeamsBotApplication` +- `IBot.OnTurnAsync` → `BotApplication.OnActivity` +- `ITurnContext` → `CoreActivity` +- `IConnectorClient` → `ConversationClient` +- `UserTokenClient` → `UserTokenClient` + +#### 2. **Wrapper Pattern for Clients** + +Wraps Core SDK clients to implement Bot Framework v4 interfaces. + +```mermaid +graph TB + subgraph "Bot Framework Interfaces" + IConnector[IConnectorClient] + IConversations[IConversations] + IUserToken[UserTokenClient BF] + end + + subgraph "Compatibility Wrappers" + CompatConn[CompatConnectorClient] + CompatConv[CompatConversations] + CompatToken[CompatUserTokenClient] + end + + subgraph "Core SDK Clients" + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + end + + CompatConn -.implements.-> IConnector + CompatConv -.implements.-> IConversations + CompatToken -.implements.-> IUserToken + + CompatConn --> CompatConv + CompatConv --> ConvClient + CompatToken --> TokenClient + + style CompatConn fill:#ff3b30 + style CompatConv fill:#ff3b30 + style CompatToken fill:#ff3b30 +``` + +#### 3. **Static Helper Adaptation Pattern** + +Replicates Bot Framework TeamsInfo static methods using Core SDK. + +```mermaid +graph LR + subgraph "Bot Framework v4" + TeamsInfo[TeamsInfo static class] + end + + subgraph "Compatibility Layer" + TeamsApiClient[TeamsApiClient static class] + Conversions[ActivitySchemaMapper Extensions] + end + + subgraph "Core SDK" + ConvClient[ConversationClient] + TeamsAPI[TeamsApiClient] + end + + TeamsInfo -.replicated by.-> TeamsApiClient + + TeamsApiClient --> ConvClient + TeamsApiClient --> TeamsAPI + TeamsApiClient --> Conversions + + Conversions --> JSONRoundTrip["JSON Round-Trip Serialization"] + Conversions --> DirectMap["Direct Property Mapping"] + + style TeamsApiClient fill:#ff9500 +``` + +**Key Methods** (19 total): +- Member operations: GetMemberAsync, GetPagedMembersAsync, etc. +- Meeting operations: GetMeetingInfoAsync, SendMeetingNotificationAsync +- Team operations: GetTeamDetailsAsync, GetTeamChannelsAsync +- Batch operations: SendMessageToListOfUsersAsync, GetOperationStateAsync + +#### 4. **Middleware Bridge Pattern** + +Allows Bot Framework middleware to work with Core SDK middleware pipeline. + +```mermaid +sequenceDiagram + participant Core as Core Pipeline + participant Bridge as CompatAdapterMiddleware + participant BFMiddleware as Bot Framework Middleware + participant Next as Next Handler + + Core->>Bridge: OnTurnAsync(activity, next) + Bridge->>Bridge: Convert CoreActivity to Activity + Bridge->>Bridge: Create TurnContext + Bridge->>BFMiddleware: OnTurnAsync(turnContext, nextDelegate) + BFMiddleware->>Next: nextDelegate() + Next-->>BFMiddleware: Complete + BFMiddleware-->>Bridge: Complete + Bridge->>Core: await next(activity) + Core-->>Bridge: Complete +``` + +#### 5. **Model Conversion Pattern** + +Two strategies for converting between Bot Framework and Core models: + +**Strategy 1: Direct Property Mapping** +```csharp +public static TeamsChannelAccount ToCompatTeamsChannelAccount( + this TeamsConversationAccount account) +{ + return new TeamsChannelAccount + { + Id = account.Id, + Name = account.Name, + AadObjectId = account.AadObjectId, + Email = account.Email, + GivenName = account.GivenName, + Surname = account.Surname, + UserPrincipalName = account.UserPrincipalName, + UserRole = account.UserRole, + TenantId = account.TenantId + }; +} +``` + +**Strategy 2: JSON Round-Trip** (for complex models) +```csharp +public static TeamDetails ToCompatTeamDetails(this Apps.TeamDetails teamDetails) +{ + var json = System.Text.Json.JsonSerializer.Serialize(teamDetails); + return Newtonsoft.Json.JsonConvert.DeserializeObject(json)!; +} +``` + +### Key Components + +| Component | Purpose | Pattern | +|-----------|---------|---------| +| `TeamsBotFrameworkHttpAdapter` | Main adapter implementing Bot Framework interface | Adapter | +| `TeamsBotAdapter` | Base adapter for turn context creation | Adapter | +| `CompatConnectorClient` | Wraps connector client functionality | Wrapper | +| `CompatConversations` | Wraps conversation operations | Wrapper | +| `CompatUserTokenClient` | Wraps token client functionality | Wrapper | +| `CompatAdapterMiddleware` | Bridges middleware systems | Bridge | +| `TeamsApiClient` | Static helper methods for Teams operations | Static Helper | +| `ActivitySchemaMapper` | Extension methods for model conversion | Extension Methods | + +### Migration Path + +```mermaid +graph LR + subgraph Phase1["Phase 1: Drop-in Replacement"] + BFBot1[Existing Bot Framework Bot] + AddAdapter1[services.AddTeamsBotFrameworkHttpAdapter] + BFBot1 --> AddAdapter1 + end + + subgraph Phase2["Phase 2: Incremental Migration"] + BFBot2[Mixed Usage] + UseCore[Use Core SDK for new features] + KeepBF[Keep BF code for existing] + BFBot2 --> UseCore + BFBot2 --> KeepBF + end + + subgraph Phase3["Phase 3: Full Migration"] + CoreBot[Pure Teams SDK Bot] + TeamsBotApp[TeamsBotApplication] + Handlers[Typed Handlers] + CoreBot --> TeamsBotApp + CoreBot --> Handlers + end + + AddAdapter1 -.Next Phase.-> BFBot2 + KeepBF -.Next Phase.-> CoreBot + + style Phase1 fill:#ff3b30 + style Phase2 fill:#ff9500 + style Phase3 fill:#34c759 +``` + +### Configuration + +```csharp +services.AddTeamsBotFrameworkHttpAdapter(configuration); +// Registers everything from Apps plus: +// - TeamsBotFrameworkHttpAdapter as IBotFrameworkHttpAdapter (Singleton) +// - TeamsBotAdapter (Singleton) +``` + +--- + +## Cross-Cutting Patterns + +### 1. **Dependency Injection Pattern** + +All three projects use ASP.NET Core DI extensively. + +```mermaid +graph TB + subgraph "DI Container" + Services[IServiceCollection] + end + + subgraph "Core Registrations" + BotApp[BotApplication] + ConvClient[ConversationClient] + TokenClient[UserTokenClient] + HttpClient[BotHttpClient] + end + + subgraph "Apps Registrations" + TeamsBotApp[TeamsBotApplication] + TeamsAPI[TeamsApiClient] + HttpCtx[IHttpContextAccessor] + end + + subgraph "Compat Registrations" + Adapter[TeamsBotFrameworkHttpAdapter] + BotAdapter[TeamsBotAdapter] + end + + Services --> BotApp + Services --> ConvClient + Services --> TokenClient + Services --> HttpClient + Services --> TeamsBotApp + Services --> TeamsAPI + Services --> HttpCtx + Services --> Adapter + Services --> BotAdapter + + TeamsBotApp -.extends.-> BotApp + Adapter -.uses.-> TeamsBotApp +``` + +### 2. **Configuration Pattern** + +Hierarchical configuration with conventions. + +```csharp +{ + "AzureAd": { + "ClientId": "...", + "TenantId": "...", + "ClientSecret": "..." + }, + "MicrosoftAppId": "...", + "MicrosoftAppPassword": "...", + "MicrosoftAppType": "MultiTenant" +} +``` + +**Configuration Precedence**: +1. Environment variables +2. appsettings.json +3. Configuration section (AzureAd, etc.) +4. Fallback defaults + +### 3. **Authentication Pattern** + +JWT bearer token authentication for API calls. + +```mermaid +sequenceDiagram + participant Client as Bot Client + participant Auth as BotAuthenticationHandler + participant AAD as Azure AD + participant API as Teams API + + Client->>Auth: Request with credentials + Auth->>AAD: Get access token + AAD-->>Auth: JWT token + Auth->>Auth: Add Authorization header + Auth->>API: Request with Bearer token + API-->>Auth: Response + Auth-->>Client: Response +``` + +### 4. **Error Handling Pattern** + +Structured exception handling with custom exceptions. + +```csharp +public class BotHandlerException : Exception +{ + public CoreActivity Activity { get; } + public BotHandlerException(CoreActivity activity, string message, Exception? innerException) + : base(message, innerException) + { + Activity = activity; + } +} +``` + +### 5. **Logging Pattern** + +Structured logging with scopes and log levels. + +```csharp +using (_logger.BeginScope("Processing activity {Type} {Id}", activity.Type, activity.Id)) +{ + _logger.LogInformation("Processing activity {Type}", activity.Type); + _logger.LogTrace("Activity details: {Activity}", activity.ToJson()); +} +``` + +--- + +## Performance Considerations + +### 1. **System.Text.Json Source Generation** + +- **Core SDK**: Uses source-generated JSON serializers for zero-allocation deserialization +- **AOT Ready**: Supports ahead-of-time compilation +- **Performance**: 2-3x faster than reflection-based serialization + +### 2. **Object Pooling** + +- Reuses objects where possible to reduce GC pressure +- Particularly important for high-throughput scenarios + +### 3. **Async/Await Best Practices** + +- ConfigureAwait(false) used throughout to avoid context switching +- Cancellation token support for graceful shutdown +- ValueTask for hot paths where appropriate + +### 4. **Minimal Allocations** + +- Uses Span and Memory where applicable +- Avoids unnecessary string allocations +- Lazy initialization of expensive resources + +--- + +## Testing Strategy + +```mermaid +graph TB + subgraph "Test Levels" + Unit[Unit Tests] + Integration[Integration Tests] + E2E[End-to-End Tests] + end + + subgraph "Test Projects" + CoreTests[Microsoft.Teams.Core.UnitTests] + AppsTests[Microsoft.Teams.Apps.UnitTests] + CompatTests[Microsoft.Teams.Apps.BotBuilder.UnitTests] + IntTests[Microsoft.Teams.Core.Tests] + end + + Unit --> CoreTests + Unit --> AppsTests + Unit --> CompatTests + + Integration --> IntTests + + style Unit fill:#34c759 + style Integration fill:#ff9500 + style E2E fill:#ff3b30 +``` + +### Test Patterns + +1. **Unit Tests**: Mock dependencies, test in isolation +2. **Integration Tests**: Test with live services (requires credentials) +3. **Compatibility Tests**: Verify Bot Framework v4 compatibility + +--- + +## Summary + +### Design Principles + +1. **Separation of Concerns**: Clear layering with distinct responsibilities +2. **Dependency Inversion**: Depend on abstractions, not implementations +3. **Single Responsibility**: Each class has one reason to change +4. **Open/Closed**: Open for extension, closed for modification +5. **Performance First**: Optimized for high-throughput scenarios +6. **Backward Compatibility**: Smooth migration path from Bot Framework v4 + +### Key Takeaways + +| Layer | Primary Pattern | Main Benefit | +|-------|----------------|--------------| +| **Core** | Middleware Pipeline | Extensible activity processing | +| **Apps** | Handler Pattern | Type-safe Teams-specific routing | +| **Compat** | Adapter Pattern | Seamless migration from Bot Framework v4 | + +### Evolution Path + +```mermaid +timeline + title SDK Evolution + Phase 1 (Current) : Bot Framework v4 with Compat layer + Phase 2 (Transition) : Mixed usage - Core for new features + Phase 3 (Target) : Pure Teams SDK with typed handlers + Phase 4 (Future) : Cloud-native with additional performance optimizations +``` diff --git a/core/docs/CompatTeamsInfo-API-Mapping.md b/core/docs/CompatTeamsInfo-API-Mapping.md new file mode 100644 index 000000000..8af4a07b8 --- /dev/null +++ b/core/docs/CompatTeamsInfo-API-Mapping.md @@ -0,0 +1,161 @@ +# TeamsApiClient API Mapping + +This document provides a comprehensive mapping of Bot Framework TeamsInfo static methods to their corresponding REST API endpoints and the Teams Bot Core SDK client implementations. + +## Overview + +The `TeamsApiClient` class provides a compatibility layer that adapts the Bot Framework v4 SDK TeamsInfo API to use the Teams Bot Core SDK. It maps 19 static methods organized into four functional categories. + +## API Method Mappings + +### Member & Participant Methods + +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `GetMemberAsync` | `GET /v3/conversations/{conversationId}/members/{userId}` | ConversationClient | Implemented | +| `GetMembersAsync` | `GET /v3/conversations/{conversationId}/members` | ConversationClient | Implemented (deprecated) | +| `GetPagedMembersAsync` | `GET /v3/conversations/{conversationId}/pagedmembers?pageSize=&continuationToken=` | ConversationClient | Implemented | +| `GetTeamMemberAsync` | `GET /v3/conversations/{teamId}/members/{userId}` | ConversationClient | Implemented | +| `GetTeamMembersAsync` | `GET /v3/conversations/{teamId}/members` | ConversationClient | Implemented (deprecated) | +| `GetPagedTeamMembersAsync` | `GET /v3/conversations/{teamId}/pagedmembers?pageSize=&continuationToken=` | ConversationClient | Implemented | + +> `GetMembersAsync` and `GetTeamMembersAsync` are deprecated by Microsoft Teams. Use paged versions instead. + +### Meeting Methods + +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `GetMeetingInfoAsync` | `GET /v1/meetings/{meetingId}` | ApiClient.Meetings | Implemented | +| `GetMeetingParticipantAsync` | `GET /v1/meetings/{meetingId}/participants/{participantId}?tenantId={tenantId}` | ApiClient.Meetings | Implemented | +| `SendMeetingNotificationAsync` | `POST /v1/meetings/{meetingId}/notification` | — | Commented out (needs `MeetingClient.SendMeetingNotificationAsync`) | + +> `GetMeetingParticipantAsync` requires an AAD object ID for `participantId`, not a Bot Framework MRI or pairwise ID. + +### Team & Channel Methods + +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `GetTeamDetailsAsync` | `GET /v3/teams/{teamId}` | ApiClient.Teams | Implemented — uses `client.Teams.GetByIdAsync()` | +| `GetTeamChannelsAsync` | `GET /v3/teams/{teamId}/conversations` | ApiClient.Teams | Implemented — uses `client.Teams.GetConversationsAsync()` | + +### Batch Messaging Methods + +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `SendMessageToListOfUsersAsync` | `POST /v3/batch/conversation/users/` | — | Commented out (needs BatchClient) | +| `SendMessageToListOfChannelsAsync` | `POST /v3/batch/conversation/channels/` | — | Commented out (needs BatchClient) | +| `SendMessageToAllUsersInTeamAsync` | `POST /v3/batch/conversation/team/` | — | Commented out (needs BatchClient) | +| `SendMessageToAllUsersInTenantAsync` | `POST /v3/batch/conversation/tenant/` | — | Commented out (needs BatchClient) | +| `SendMessageToTeamsChannelAsync` | Uses Bot Framework Adapter | BotAdapter.CreateConversationAsync | Implemented | + +### Batch Operation Management Methods + +| Method | REST Endpoint | Client | Status | +|--------|--------------|--------|--------| +| `GetOperationStateAsync` | `GET /v3/batch/conversation/{operationId}` | — | Commented out (needs BatchClient) | +| `GetPagedFailedEntriesAsync` | `GET /v3/batch/conversation/failedentries/{operationId}?continuationToken=` | — | Commented out (needs BatchClient) | +| `CancelOperationAsync` | `DELETE /v3/batch/conversation/{operationId}` | — | Commented out (needs BatchClient) | + +## Client Distribution + +### ConversationClient (6 methods) — Implemented + +Used for member and participant operations in conversations and teams. Accessed via the `CompatConnectorClient` in TurnState (`turnContext.TurnState.Get()` → cast to `CompatConnectorClient` → `CompatConversations._client`). + +- GetMemberAsync +- GetMembersAsync +- GetPagedMembersAsync +- GetTeamMemberAsync +- GetTeamMembersAsync +- GetPagedTeamMembersAsync + +### ApiClient sub-clients (4 methods) — Implemented + +`ApiClient` is stored in TurnState by `TeamsBotFrameworkHttpAdapter`. Must be scoped to serviceUrl via `ForServiceUrl()` before use. Uses sub-clients: + +- `ApiClient.Meetings.GetByIdAsync()` — GetMeetingInfoAsync +- `ApiClient.Meetings.GetParticipantAsync()` — GetMeetingParticipantAsync +- `ApiClient.Teams.GetByIdAsync()` — GetTeamDetailsAsync +- `ApiClient.Teams.GetConversationsAsync()` — GetTeamChannelsAsync + +### Bot Framework Adapter (1 method) — Implemented + +- SendMessageToTeamsChannelAsync — uses `turnContext.Adapter.CreateConversationAsync()` + +### Not yet implemented (8 methods) — Commented out + +These methods are commented out in `TeamsApiClient` pending new client support: + +- SendMeetingNotificationAsync — needs `MeetingClient.SendMeetingNotificationAsync` +- SendMessageToListOfUsersAsync — needs BatchClient +- SendMessageToListOfChannelsAsync — needs BatchClient +- SendMessageToAllUsersInTeamAsync — needs BatchClient +- SendMessageToAllUsersInTenantAsync — needs BatchClient +- GetOperationStateAsync — needs BatchClient +- GetPagedFailedEntriesAsync — needs BatchClient +- CancelOperationAsync — needs BatchClient + +## Migration Checklist + +| Item | Status | +|---|---| +| Member operations via ConversationClient | Done | +| Meeting info via ApiClient.Meetings | Done | +| Meeting participant via ApiClient.Meetings | Done | +| Team details via ApiClient.Teams | Done | +| Team channels via ApiClient.Teams | Done | +| SendMessageToTeamsChannelAsync via adapter | Done | +| Batch messaging (4 methods) | Needs BatchClient on ApiClient | +| Batch operations (3 methods) | Needs BatchClient on ApiClient | +| Meeting notifications | Needs MeetingClient.SendMeetingNotificationAsync | +| TeamsBotFrameworkHttpAdapter scopes ApiClient per-request | Needs update to call ForServiceUrl | + +## Type Conversions + +Key extension methods in `ActivitySchemaMapper.cs` and `TeamsApiClient.Models.cs`: + +| Extension Method | Source Type | Target Type | Used By | Status | +|---|---|---|---|---| +| `ToCompatTeamsChannelAccount` | `ConversationAccount` | BF `TeamsChannelAccount` | GetMember/GetMembers/GetTeamMember/GetTeamMembers | Working | +| `ToCompatTeamsPagedMembersResult` | `PagedMembersResult` | BF `TeamsPagedMembersResult` | GetPagedMembers/GetPagedTeamMembers | Working | +| `ToCompatChannelInfo` | `TeamsChannel` | BF `ChannelInfo` | GetTeamChannelsAsync | Working | +| `ToCompatTeamDetails` | `Apps.Schema.Team` | BF `TeamDetails` | Defined but unused — GetTeamDetailsAsync uses inline mapping | Available | +| `ToCompatTeamsMeetingParticipant` | `MeetingParticipant` | BF `TeamsMeetingParticipant` | Defined but unused — GetMeetingParticipantAsync uses inline mapping | Available | +| `ToCompatBatchOperationState` | `BatchOperationState` | BF `BatchOperationState` | — | Commented out (needs models) | +| `ToCompatBatchFailedEntriesResponse` | `BatchFailedEntriesResponse` | BF `BatchFailedEntriesResponse` | — | Commented out (needs models) | +| `ToCompatMeetingNotificationResponse` | `MeetingNotificationResponse` | BF `MeetingNotificationResponse` | — | Commented out (needs models) | +| `FromCompatTeamMember` | BF `TeamMember` | `Apps.TeamMember` | — | Commented out (needs models) | + +## Authentication + +All methods use `AgenticIdentity` extracted from the turn context activity properties for authentication with the Teams services. The identity is obtained by converting the Bot Framework `Activity` to a `CoreActivity` and extracting agentic properties from `From.Properties`. + +## Service URL + +All API calls use the service URL from the turn context activity (`turnContext.Activity.ServiceUrl`): + +- **ConversationClient** methods receive `serviceUrl` as a `Uri` parameter directly +- **ApiClient** sub-client methods use the serviceUrl baked into the scoped client instance + +The `TeamsBotFrameworkHttpAdapter` must store a **scoped** `ApiClient` in TurnState for Teams/Meetings sub-clients to work. Currently it stores the unscoped base instance — this is a known pending fix (see [ApiClient Design](ApiClient-Design.md#integration-with-compatteamsinfo)). + +## Testing + +Integration tests are available in `core/test/IntegrationTests/`: + +| Test File | Coverage | +|---|---| +| `TeamsApiClientTests.cs` | 14 tests covering all implemented TeamsApiClient methods via real API calls with a simulated TurnContext | +| `ApiClientTests.cs` | Direct tests for ApiClient sub-clients (Activities, Members, Teams, Meetings, UserToken, BotSignIn) | +| `ConversationClientTests.cs` | Core ConversationClient operations | +| `CreateConversationTests.cs` | Conversation creation patterns | + +Tests require the `integration.runsettings` file with environment variables: +- `TEST_USER_ID`, `TEST_CONVERSATIONID`, `TEST_TEAMID`, `TEST_CHANNELID`, `TEST_MEETINGID`, `TEST_TENANTID` +- Azure AD credentials (`AzureAd__TenantId`, `AzureAd__ClientId`, `AzureAd__ClientSecret`) + +## References + +- [ApiClient Design Document](ApiClient-Design.md) — Architecture, delegation patterns, and scoping +- [CreateConversation API Behavior](CreateConversation-API-Behavior.md) — Detailed API behavior with request/response examples +- [Bot Framework TeamsInfo Source](https://github.com/microsoft/botbuilder-dotnet/blob/main/libraries/Microsoft.Bot.Builder/Teams/TeamsInfo.cs) diff --git a/core/docs/Core-Compat-PackageDependencies.md b/core/docs/Core-Compat-PackageDependencies.md new file mode 100644 index 000000000..b1c3a7678 --- /dev/null +++ b/core/docs/Core-Compat-PackageDependencies.md @@ -0,0 +1,227 @@ +# Package Dependencies Design Document + +This document describes the package dependency changes introduced in the `next/core-decouple-fe` PR within the `core/` SDK. The key change is decoupling `Bot.Compat` from `Bot.Apps` so that both depend directly on `Bot.Core` as independent siblings. + +--- + +## Before: Linear Dependency Chain + +Prior to this PR, `Bot.Compat` depended on `Bot.Apps`, which in turn depended on `Bot.Core`. This created a **linear chain** where Compat transitively pulled in everything from Apps. + +```mermaid +graph BT + Core["Microsoft.Teams.Core
net8.0 · net10.0
Foundation Layer"] + Apps["Microsoft.Teams.Apps
net8.0 · net10.0
Teams Features Layer"] + Compat["Microsoft.Teams.Apps.BotBuilder
net8.0 · net10.0
Compatibility Layer"] + + Apps -->|"ProjectReference"| Core + Compat -->|"ProjectReference"| Apps + + style Core fill:#e1f5ff,stroke:#0d6efd + style Apps fill:#fff4e1,stroke:#fd7e14 + style Compat fill:#ffe1f5,stroke:#d63384 +``` + +### Problems with this structure + +- **Unnecessary coupling**: `Bot.Compat` only needs `Bot.Core` (activity model, conversation client, hosting), but was forced to take a dependency on the entire `Bot.Apps` layer (Teams-specific handlers, routing, streaming, Teams API client). +- **Larger transitive closure**: Any consumer of `Bot.Compat` also pulled in `Bot.Apps` as a transitive dependency, even if they never used Teams-specific features. +- **Breaking change risk**: Changes to `Bot.Apps` could break `Bot.Compat` consumers even when the Compat layer only used Core types. +- **InternalsVisibleTo gap**: `Bot.Core` only exposed internals to `Bot.Apps`, so `Bot.Compat` had to go through Apps to access Core internals. + +--- + +## After: Sibling Architecture + +This PR changes `Bot.Compat` to reference `Bot.Core` directly instead of `Bot.Apps`. Both `Apps` and `Compat` are now **independent siblings** that share only the `Core` foundation. + +```mermaid +graph BT + Core["Microsoft.Teams.Core
net8.0 · net10.0
Foundation Layer"] + Apps["Microsoft.Teams.Apps
net8.0 · net10.0
Teams Features Layer"] + Compat["Microsoft.Teams.Apps.BotBuilder
net8.0 · net10.0
Compatibility Layer"] + + Apps -->|"ProjectReference"| Core + Compat -->|"ProjectReference"| Core + + style Core fill:#e1f5ff,stroke:#0d6efd + style Apps fill:#fff4e1,stroke:#fd7e14 + style Compat fill:#ffe1f5,stroke:#d63384 +``` + +--- + +## Side-by-Side Comparison + +```mermaid +graph TB + subgraph "Before" + direction BT + B_Core["Bot.Core"] + B_Apps["Bot.Apps"] + B_Compat["Bot.Compat"] + B_Apps -->|"ProjectReference"| B_Core + B_Compat -->|"ProjectReference"| B_Apps + end + + subgraph "After" + direction BT + A_Core["Bot.Core"] + A_Apps["Bot.Apps"] + A_Compat["Bot.Compat"] + A_Apps -->|"ProjectReference"| A_Core + A_Compat -->|"ProjectReference"| A_Core + end + + style B_Core fill:#e1f5ff + style B_Apps fill:#fff4e1 + style B_Compat fill:#ffe1f5 + style A_Core fill:#e1f5ff + style A_Apps fill:#fff4e1 + style A_Compat fill:#ffe1f5 +``` + +| Metric | Before | After | +|--------|--------|-------| +| Dependency depth from Compat | 3 (Compat → Apps → Core) | 2 (Compat → Core) | +| Compat's transitive project refs | 2 (Apps + Core) | 1 (Core) | +| Packages coupled to Bot.Apps | Apps + Compat | Apps only | +| Core InternalsVisibleTo | Apps, Core.UnitTests | Apps, **Compat**, Core.UnitTests | + +--- + +## What Changed + +### 1. `Bot.Compat.csproj` — dependency target changed + +```diff + +- ++ + +``` + +### 2. `Bot.Core.csproj` — InternalsVisibleTo added for Compat + +```diff + + + ++ + +``` + +### 3. Compat source code — rewritten to use Core types directly + +Types in `Bot.Compat` (e.g., `ActivitySchemaMapper`, `TeamsApiClient`, `CompatHostingExtensions`) were updated to import from `Microsoft.Teams.Core` namespaces instead of going through `Microsoft.Teams.Apps`. + +--- + +## InternalsVisibleTo Relationships + +Before, only `Bot.Apps` could access Core internals. Now both sibling packages can. + +```mermaid +graph LR + Core["Bot.Core"] + Apps["Bot.Apps"] + Compat["Bot.Compat"] + CoreTests["Bot.Core.UnitTests"] + AppsTests["Bot.Apps.UnitTests"] + + Core -.->|"InternalsVisibleTo"| Apps + Core -.->|"InternalsVisibleTo
(new)"| Compat + Core -.->|"InternalsVisibleTo"| CoreTests + + Apps -.->|"InternalsVisibleTo"| AppsTests + + style Core fill:#e1f5ff + style Apps fill:#fff4e1 + style Compat fill:#ffe1f5 + style CoreTests fill:#f0f0f0 + style AppsTests fill:#f0f0f0 +``` + +--- + +## NuGet Dependencies Per Layer + +The external NuGet dependency layout is unchanged — but the transitive impact is different: + +```mermaid +graph TD + subgraph "Bot.Core" + C1["AspNetCore.Authentication.JwtBearer"] + C2["AspNetCore.Authentication.OpenIdConnect"] + C3["System.Security.Cryptography.Pkcs"] + C4["Microsoft.Identity.Web.UI"] + C5["Microsoft.Identity.Web.AgentIdentities"] + end + + subgraph "Bot.Apps" + A1["(no external NuGet packages)"] + end + + subgraph "Bot.Compat" + X1["Microsoft.Bot.Builder.Integration
.AspNet.Core 4.22.3"] + end + + style C1 fill:#e1f5ff + style C2 fill:#e1f5ff + style C3 fill:#e1f5ff + style C4 fill:#e1f5ff + style C5 fill:#e1f5ff + style A1 fill:#fff4e1 + style X1 fill:#ffe1f5 +``` + +**Before**: A `Bot.Compat` consumer transitively received all NuGet packages from Core **plus** the entire `Bot.Apps` assembly. + +**After**: A `Bot.Compat` consumer only receives Core's NuGet packages. `Bot.Apps` is no longer in the transitive closure. + +--- + +## Sample Application Dependency Patterns + +Samples demonstrate three independent entry points: + +```mermaid +graph BT + Core["Bot.Core"] + Apps["Bot.Apps"] + Compat["Bot.Compat"] + + CoreBot["CoreBot
net10.0"] + TeamsBot["TeamsBot
net10.0"] + CompatBot["CompatBot
net8.0"] + + CoreBot --> Core + TeamsBot --> Apps + CompatBot --> Compat + + Apps --> Core + Compat --> Core + + style Core fill:#e1f5ff,stroke:#0d6efd + style Apps fill:#fff4e1,stroke:#fd7e14 + style Compat fill:#ffe1f5,stroke:#d63384 + style CoreBot fill:#f0f0f0 + style TeamsBot fill:#f0f0f0 + style CompatBot fill:#f0f0f0 +``` + +| Entry Point | When to Use | +|-------------|-------------| +| **Bot.Core** directly | Minimal bots needing only core activity handling, middleware, and conversation client | +| **Bot.Apps** | Teams-specific bots with typed handlers, routing, streaming, Teams API client | +| **Bot.Compat** | Migrating existing Bot Framework v4 bots — no longer pulls in Bot.Apps transitively | + +--- + +## Design Rationale + +1. **Decoupled Compat from Apps**: `Bot.Compat` only needs Core primitives (activities, conversation client, hosting). Removing the Apps dependency eliminates unnecessary coupling. +2. **Smaller transitive closure**: Consumers of `Bot.Compat` no longer pull in the entire Teams-specific layer (`Bot.Apps`) as a transitive dependency. +3. **Independent evolution**: `Bot.Apps` and `Bot.Compat` can now be versioned and modified independently without risk of cross-impact. +4. **Direct internal access**: Adding `InternalsVisibleTo` for Compat on Core removes the need to route through Apps to access shared infrastructure like `BotHttpClient` and serialization contexts. +5. **Clearer architecture**: The sibling pattern makes the SDK's layering explicit — Core is the shared foundation, Apps adds Teams features, Compat bridges to Bot Framework v4. diff --git a/core/docs/CreateConversation-API-Behavior.md b/core/docs/CreateConversation-API-Behavior.md new file mode 100644 index 000000000..165f1a988 --- /dev/null +++ b/core/docs/CreateConversation-API-Behavior.md @@ -0,0 +1,432 @@ +# CreateConversation API Behavior + +Technical reference documenting the exact behavior of the Teams Bot Framework `POST /v3/conversations` endpoint, based on integration test results captured on 2026-04-17. + +## Endpoint + +``` +POST {serviceUrl}/v3/conversations +Content-Type: application/json; charset=utf-8 +Authorization: Bearer {token} +``` + +Service URL: `https://smba.trafficmanager.net/teams/` + +## Supported Conversation Types + +The endpoint supports exactly **two** conversation creation patterns: + +1. **1:1 Personal Chat** (proactive messaging to a single user) +2. **Channel Thread** (new thread in an existing Teams channel) + +**Group chat creation is NOT supported** — every variation returns `400 BadSyntax`. + +--- + +## 1:1 Personal Chat + +### Minimal (working) + +```http +POST https://smba.trafficmanager.net/teams/v3/conversations + +{ + "isGroup": false, + "members": [ + { + "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" + } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +Server: Microsoft-HTTPAPI/2.0 +MS-CV: p82ptW4x80GRgeZm9NkMlQ.0 +Content-Type: application/json; charset=utf-8 +Content-Length: 140 + +{ + "id": "a:1p0iicaJlVi-_KIYKDDvLi4c2pMZMc8B0bPauUJq9pZ6IHPzMOrXbWS4g7Wktn1hwl8J3FecCj4cn33DInsp7AGj8mSSb23S5cQJTjU_CXlYs-eph-CchluBdnSKVFm40" +} +``` + +**Notes:** +- `isGroup` must be `false` +- `members` must contain exactly 1 member +- Member ID must be in MRI format (`29:...`), not pairwise bot framework ID (`29:guid`) +- `tenantId` is required +- Response `id` starts with `a:` prefix (personal chat conversation ID) +- Calling with the same member returns the same conversation ID (idempotent) + +### With bot specified (working) + +```http +{ + "isGroup": false, + "bot": { + "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" + }, + "members": [ + { + "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" + } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +MS-CV: 2yFg6cTgzUeVB8q/F9pHkA.0 +``` + +**Notes:** +- `bot.id` uses `28:{appId}` format +- Bot field is optional for 1:1 — the API infers the bot from the auth token +- Same response as without bot + +### With initial activity (working) + +```http +{ + "isGroup": false, + "members": [ + { + "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" + } + ], + "activity": { + "type": "message", + "text": "[Diagnostic] 1:1 with initial activity" + }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +MS-CV: PB7kLrArfE6r21I3q5gMRA.0 + +{ + "id": "a:1p0iicaJlVi-_KIYKDDvLi4c2pMZMc8B0bPauUJq9pZ6IHPzMOrXbWS4g7Wktn1hwl8J3FecCj4cn33DInsp7AGj8mSSb23S5cQJTjU_CXlYs-eph-CchluBdnSKVFm40" +} +``` + +**Notes:** +- The activity is sent as the first message in the conversation +- Response does NOT include `activityId` (unlike channel threads) +- If the conversation already exists, the activity is still sent + +--- + +## Channel Thread + +### With activity (working) + +```http +{ + "isGroup": true, + "activity": { + "type": "message", + "text": "[Diagnostic] channel thread" + }, + "channelData": { + "channel": { + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2" + } + }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +Server: Microsoft-HTTPAPI/2.0 +MS-CV: 7hdK6FlaqE+BjXxKvUCQUg.0 +Content-Type: application/json; charset=utf-8 +Content-Length: 122 + +{ + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2;messageid=1776390257332", + "activityId": "1776390257332" +} +``` + +**Notes:** +- `isGroup` must be `true` +- `channelData.channel.id` must reference a valid channel +- `activity` is **required** — the thread root message +- Response `id` is `{channelId};messageid={messageId}` (the thread conversation ID) +- Response includes `activityId` (the thread root message ID, used for replies) +- `members` is NOT required (thread is visible to all channel members) + +### With members and activity (working) + +```http +{ + "isGroup": true, + "members": [ + { + "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" + } + ], + "activity": { + "type": "message", + "text": "[Diagnostic] channel thread with members" + }, + "channelData": { + "channel": { + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2" + } + }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 201 Created +MS-CV: +YAgno/+yUqSpHSvnRVcOQ.0 + +{ + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2;messageid=1776390250598", + "activityId": "1776390250598" +} +``` + +**Notes:** +- Adding `members` to a channel thread request does not cause an error +- The members field appears to be ignored (thread visibility is determined by channel membership) + +### Without activity (FAILS) + +```http +{ + "isGroup": true, + "channelData": { + "channel": { + "id": "19:LydFnezGKSkhYoiLNP6kZ8AuXQr36EDAkvG9CNJSPKc1@thread.tacv2" + } + }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +Server: Microsoft-HTTPAPI/2.0 +MS-CV: 1juAJRUj5ki4igxWv3Y8EQ.0 +Content-Type: application/json; charset=utf-8 +Content-Length: 85 + +{ + "error": { + "code": "BadSyntax", + "message": "Incorrect conversation creation parameters" + } +} +``` + +**Conclusion:** `activity` is mandatory for channel thread creation. You cannot create an empty thread. + +--- + +## Group Chat (NOT SUPPORTED) + +All of the following variations return the same `400 BadSyntax` error. The `MS-CV` header is included for each to enable service-side log correlation. + +### 2 members, no bot, no channelData + +```http +{ + "isGroup": true, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: /qe9JFWupEGpNA9S/vIXaQ.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 2 members, with bot + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: Sm3G0wjzV0y2EKXpeJu7jQ.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 2 members, bot, channelData.tenant + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" } + ], + "channelData": { "tenant": { "id": "3f3d1cea-7a18-41af-872b-cfbbd5140984" } }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: lmTBZpEylUeAiMTGSPnpVQ.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 2 members, bot, topic, activity, channelData (all fields) + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" } + ], + "topicName": "Diagnostic group test", + "activity": { "type": "message", "text": "group chat init" }, + "channelData": { "tenant": { "id": "3f3d1cea-7a18-41af-872b-cfbbd5140984" } }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: zkVf7eA6BEytpPgWI8KH9Q.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 1 member, isGroup=true + +```http +{ + "isGroup": true, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" } + ], + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: yP2h6kv4iUG08nVbdcQJ0g.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 1 member, bot, channelData.tenant + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" } + ], + "channelData": { "tenant": { "id": "3f3d1cea-7a18-41af-872b-cfbbd5140984" } }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: 10bRBNHyxk+eigCT8saVDg.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +### 3 members, bot, channelData.tenant + +```http +{ + "isGroup": true, + "bot": { "id": "28:3738fe3d-bca2-479d-8e45-1660de89ee41" }, + "members": [ + { "id": "29:1aK9mYhSZ3egG5Ve2UaOoEjrOppWz-gl7AQmsXeW-4XS1et5FiZ3_V45othuWHgfY0Ytv82M6WnH8lRI8gLMeHg" }, + { "id": "29:100DQ6CrcJc9p_L654DvdNtwAazXhnxkoNAedgV0ZAgalPOz0oy7RmLG0VKCPhdia_w0lJJLUp0QEw6ogU7zyWg" }, + { "id": "29:1wh0NxivaCTCGl7pmILex0arFbszG6RaKMMOXImiDOCu3-T1qzkGdsmA_AfFpawkDaQl0kfvVy9RkVWQNGl30-w" } + ], + "channelData": { "tenant": { "id": "3f3d1cea-7a18-41af-872b-cfbbd5140984" } }, + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984" +} +``` + +```http +HTTP/1.1 400 Bad Request +MS-CV: gfacBHOnI0CQ+n6Nxim+1w.0 + +{ "error": { "code": "BadSyntax", "message": "Incorrect conversation creation parameters" } } +``` + +--- + +## Summary Table + +| Scenario | `isGroup` | `channelData.channel.id` | `activity` | `members` | HTTP | Result | +|---|---|---|---|---|---|---| +| 1:1 personal chat | `false` | — | optional | 1 (required) | **201** | Conversation created | +| 1:1 with bot | `false` | — | optional | 1 (required) | **201** | Conversation created | +| 1:1 with initial activity | `false` | — | message | 1 (required) | **201** | Conversation + message | +| Channel thread | `true` | required | **required** | optional | **201** | Thread created | +| Channel thread + members | `true` | required | **required** | ignored | **201** | Thread created | +| Channel thread, no activity | `true` | required | — | — | **400** | BadSyntax | +| Group: any member count | `true` | — | any | 1-3 | **400** | BadSyntax | +| Group: with bot | `true` | — | any | 1-3 | **400** | BadSyntax | +| Group: all fields | `true` | — | message | 2 | **400** | BadSyntax | + +## Response Headers + +Common response headers across all requests: + +| Header | Description | +|---|---| +| `Server` | Always `Microsoft-HTTPAPI/2.0` | +| `MS-CV` | Correlation vector for service-side log tracing | +| `Content-Type` | Always `application/json; charset=utf-8` | +| `Date` | Server-side timestamp | +| `Content-Length` | Response body size | + +The `MS-CV` header is the key diagnostic value — it can be used to correlate with Teams service-side logs for deeper investigation of `BadSyntax` failures. + +## Key Observations + +1. **Member ID format matters.** The API requires MRI-format IDs (`29:1aK9...`), not the pairwise bot framework IDs stored in `TEST_USER_ID` env vars (`29:guid`). MRI IDs can be obtained from `GET /v3/conversations/{id}/members`. + +2. **1:1 conversations are idempotent.** Calling CreateConversation with the same member always returns the same conversation ID (`a:...` prefix). + +3. **Channel threads require an activity.** You cannot create an empty thread — the initial message IS the thread. + +4. **Group chat creation is a platform limitation.** The `POST /v3/conversations` endpoint does not support creating multi-user group chats. The error is always `BadSyntax: Incorrect conversation creation parameters` regardless of parameter combinations. This applies to the Teams channel (msteams) specifically — other Bot Framework channels may behave differently. + +5. **`tenantId` is required** for all Teams conversation creation. Omitting it causes auth failures. + +6. **`bot` field is optional.** The API infers the bot identity from the bearer token for 1:1 chats. diff --git a/core/docs/MigrationGuide.md b/core/docs/MigrationGuide.md new file mode 100644 index 000000000..15108b0ba --- /dev/null +++ b/core/docs/MigrationGuide.md @@ -0,0 +1,368 @@ +# Migration Guide: Libraries/Microsoft.Teams.Apps to core/src/Microsoft.Teams.Apps + +This guide covers migrating from the old `Microsoft.Teams.Apps` library (`Libraries/`) to the new `Microsoft.Teams.Apps` library (`core/src/`). + +--- + +## Assembly Mapping + +The old library is split across 16 assemblies. The new library consolidates into 3. + +### New library assemblies + +| New Assembly | Purpose | +|---|---| +| `Microsoft.Teams.Core` | Foundation: activity protocol, auth, middleware, HTTP clients | +| `Microsoft.Teams.Apps` | High-level: handlers, routing, OAuth flows, API clients | +| `Microsoft.Teams.Apps.BotBuilder` | Backward compat layer for Bot Framework SDK | + +### Old assemblies not available in the new library + +These assemblies have no equivalent in the new library and must be sourced separately or replaced: + +| Old Assembly | Status | +|---|---| +| `Microsoft.Teams.AI` | Not available | +| `Microsoft.Teams.AI.Models.OpenAI` | Not available | +| `Microsoft.Teams.Cards` | Not available | +| `Microsoft.Teams.Extensions.Graph` | Not available | +| `Microsoft.Teams.Plugins.AspNetCore.DevTools` | Not available | +| `Microsoft.Teams.Plugins.External.Mcp` | Not available — plugin architecture removed | +| `Microsoft.Teams.Plugins.External.McpClient` | Not available — plugin architecture removed | +| `Microsoft.Teams.Apps.Testing` | Not available — use standard DI mocking instead of `TestPlugin` | + +### Old assemblies replaced by standard .NET + +| Old Assembly | Replaced By | +|---|---| +| `Microsoft.Teams.Common` (logging) | `Microsoft.Extensions.Logging` | +| `Microsoft.Teams.Common` (HTTP) | `System.Net.Http.HttpClient` + DI | +| `Microsoft.Teams.Common` (storage) | No direct replacement — `IStorage` removed | +| `Microsoft.Teams.Extensions.Configuration` | `Microsoft.Extensions.Configuration` via `BotConfig` | +| `Microsoft.Teams.Extensions.Logging` | `Microsoft.Extensions.Logging` (no bridge needed) | +| `Microsoft.Teams.Extensions.Hosting` | `TeamsBotApplicationHostingExtensions` | +| `Microsoft.Teams.Plugins.AspNetCore` | Standard ASP.NET Core middleware + `BotApplication.ProcessAsync()` | +| `Microsoft.Teams.Plugins.AspNetCore.BotBuilder` | `Microsoft.Teams.Apps.BotBuilder` (compat layer) | + +--- + +## Quick Reference + +| Old API | New API | Notes | +|---------|---------|-------| +| `builder.AddTeams()` | `builder.AddTeams()` | Now works on both `WebApplicationBuilder` and `IServiceCollection` | +| `context.Send("text", ct)` | `context.Send("text", ct)` | Same API | +| `context.Send(activity, ct)` | `context.Send(activity, ct)` | Same API | +| `context.Reply("text", ct)` | `context.Reply("text", ct)` | Same API | +| `context.Reply(activity, ct)` | `context.Reply(activity, ct)` | Same API | +| `context.Typing("text", ct)` | `context.Typing("text", ct)` | Same API | +| `context.Log.Info(...)` | `context.Log.Info(...)` | Same API, delegates to `ILogger` | +| `context.Log.Error(...)` | `context.Log.Error(...)` | Same API | +| `context.Log.Debug(...)` | `context.Log.Debug(...)` | Same API | +| `context.Log.Warn(...)` | `context.Log.Warn(...)` | Same API | +| `context.AppId` | `context.AppId` | Same API | +| `teams.OnMeetingJoin(h)` | `teams.OnMeetingJoin(h)` | Alias for `OnMeetingParticipantJoin` | +| `teams.OnMeetingLeave(h)` | `teams.OnMeetingLeave(h)` | Alias for `OnMeetingParticipantLeave` | +| `teams.Send(convId, text)` | `teams.Send(convId, text)` | Proactive messaging | +| `teams.Reply(convId, msgId, text)` | `teams.Reply(convId, msgId, text)` | Proactive threaded reply | +| `InvokeResponse(200, body)` | `InvokeResponse.Ok(body)` | Factory method available | +| `InvokeResponse(400, body)` | `InvokeResponse.Error(400, body)` | Factory method available | + +--- + +## Backward-Compatible Changes (No Migration Needed) + +These APIs have been added to the new library to match the old API surface. Existing code using these patterns will work without changes. + +### Context Convenience Methods (BC-1) + +The following methods are available on `Context`: + +```csharp +// Send a text message +await context.Send("Hello!", cancellationToken); + +// Send an activity +await context.Send(myActivity, cancellationToken); + +// Send a threaded reply +await context.Reply("This is a reply", cancellationToken); +await context.Reply(myActivity, cancellationToken); + +// Send typing indicator +await context.Typing(cancellationToken: cancellationToken); +``` + +> **Note:** `Send(AdaptiveCard)` and `Reply(AdaptiveCard)` are not yet available to avoid a dependency on `Microsoft.Teams.Cards`. Use `TeamsActivityBuilder` with `AddAdaptiveCardAttachment()` instead. + +### Context Logger (BC-2) + +`context.Log` provides `.Info()`, `.Error()`, `.Debug()`, and `.Warn()` methods: + +```csharp +context.Log.Info("Processing message"); +context.Log.Error("Something failed", ex.Message); +context.Log.Debug("Activity ID:", context.Activity.Id); +``` + +These delegate to `Microsoft.Extensions.Logging.ILogger` under the hood. The underlying `ILogger` is accessible via `context.Log.Logger` if needed. + +### Context AppId (BC-5) + +```csharp +var appId = context.AppId; // reads from TeamsBotApplication.AppId +``` + +### WebApplicationBuilder.AddTeams() (BC-7) + +Both styles work: +```csharp +// Old style (on WebApplicationBuilder) +builder.AddTeams(); + +// New style (on IServiceCollection) +builder.Services.AddTeams(); +``` + +### Meeting Handler Aliases (BC-10) + +Both old and new names work: +```csharp +// Old names +teams.OnMeetingJoin(handler); +teams.OnMeetingLeave(handler); + +// New names (preferred) +teams.OnMeetingParticipantJoin(handler); +teams.OnMeetingParticipantLeave(handler); +``` + +### Proactive Messaging (BC-8) + +```csharp +// Send proactively to a conversation +await teams.Send(conversationId, "Hello!", cancellationToken: ct); + +// Send a threaded reply proactively +await teams.Reply(conversationId, messageId, "Replying!", ct); +``` + +> **Note:** The service URL is automatically cached from incoming activities. If you need to send proactively before any activity has been received, pass a `serviceUrl` parameter to `Send()`. + +### InvokeResponse Factory Methods (BC-12) + +```csharp +// Instead of: new InvokeResponse(200, body) +return InvokeResponse.Ok(body); + +// Typed version +return InvokeResponse.Ok(response); + +// Error responses +return InvokeResponse.Error(400, errorDetails); +``` + +### MessageActivity Fluent Methods (BC-15) + +Extension methods on `MessageActivity`: + +```csharp +var msg = new MessageActivity("hello") + .WithSuggestedActions(actions) + .WithAttachmentLayout("carousel") + .AddAttachment(attachment1, attachment2); +``` + +Available: `WithText()`, `WithSuggestedActions()`, `WithTextFormat()`, `WithAttachmentLayout()`, `AddAttachment()`, `AddStreamFinal()`. + +### Activity Entity Methods + +These work on any `TeamsActivity` (including `MessageActivity`): + +```csharp +activity.AddEntity(entity); // inherited method +activity.AddAIGenerated(); // extension method +activity.AddCitation(position, appearance); // extension method +activity.AddFeedback(); // extension method +activity.AddSensitivityLabel("name"); // extension method +``` + +--- + +### App.Builder() Pattern (BC-6) + +`App.Builder()` is supported with `AddOAuth()`: + +```csharp +// This works in both old and new libraries: +var appBuilder = App.Builder() + .AddOAuth("graph"); +builder.AddTeams(appBuilder); +``` + +The following `AppBuilder` methods from the old library are **not available** and should use standard ASP.NET DI instead: + +| Old AppBuilder Method | New Equivalent | +|----------------------|----------------| +| `.AddLogger(new ConsoleLogger(...))` | `builder.Logging.AddConsole()` | +| `.AddStorage(storage)` | Register via `builder.Services.AddSingleton(...)` | +| `.AddClient(httpClient)` | Register via `builder.Services.AddHttpClient(...)` | +| `.AddCredentials(credentials)` | Configure in `appsettings.json` AzureAd section | +| `.AddPlugin(plugin)` | No equivalent — plugins are not supported in the new library | +| `.AddCloud(cloud)` | Configure via `appsettings.json` | + +--- + +## Breaking Changes Requiring Migration + +### BC-4: `context.Ref` Removed + +**Old:** +```csharp +var conversationId = context.Ref.Conversation.Id; +``` + +**New:** +```csharp +var conversationId = context.Activity.Conversation.Id; +``` + +The `Ref` property is not available. Use `context.Activity.Conversation` directly — it contains the same data. + +--- + +### BC-9: `OnSignIn` / `OnSignInFailure` Events + +**Old:** +```csharp +teams.OnSignIn(async (_, @event, cancellationToken) => { ... }); +teams.OnSignInFailure(async (context, cancellationToken) => { ... }); +``` + +**New:** +```csharp +var flow = teams.GetOAuthFlow("graph"); +flow.OnSignInComplete(async (context, token, cancellationToken) => { ... }); +flow.OnSignInFailure(async (context, cancellationToken) => { ... }); +``` + +Sign-in events are now per-flow callbacks, which is more flexible when using multiple OAuth connections. + +--- + +### BC-13: Activity Namespace Changes + +| Old Namespace | New Namespace | +|---------------|---------------| +| `Microsoft.Teams.Api.Activities` | `Microsoft.Teams.Apps.Schema` | +| `MessageActivity` | `MessageActivity` (same name) | +| `InvokeActivity` | `InvokeActivity` (same name) | +| `IActivity` | `TeamsActivity` (base class) | + +Member access (`.Text`, `.From`, `.Conversation`, `.Value`, etc.) remains the same. Only `using` statements need updating. + +--- + +### BC-17: Activity fluent `With*()` methods moved to builder + +**Old:** +```csharp +var activity = new Activity().WithFrom(account).WithConversation(conv); +``` + +**New:** +```csharp +var activity = new TeamsActivityBuilder() + .WithFrom(account) + .WithConversation(conv) + .Build(); +``` + +The base `TeamsActivity` no longer has `With*()` methods. Use `TeamsActivityBuilder` instead. + +--- + +### BC-18: Activity conversion methods replaced by factories + +**Old:** +```csharp +var msg = activity.ToMessage(); +``` + +**New:** +```csharp +var msg = MessageActivity.FromActivity(coreActivity); +``` + +--- + +### BC-21: Type incompatibilities + +| Property | Old Type | New Type | +|---|---|---| +| `Timestamp`, `LocalTimestamp` | `DateTime?` | `string?` | +| `ServiceUrl` | `string?` | `Uri?` | +| `ContentUrl`, `ThumbnailUrl` (Attachment) | `string?` | `Uri?` | +| Enums (`TextFormat`, `InputHint`, etc.) | Enum types | String constants | +| `Account` | Custom `Account` class | `ConversationAccount` / `TeamsConversationAccount` | + +--- + +### Hosting and Plugin Architecture + +The old plugin-based architecture is entirely removed. This affects: + +| Old Pattern | New Equivalent | +|---|---| +| `ISenderPlugin` / `IAspNetCorePlugin` | Not available — use `TeamsBotApplication` directly | +| `AddTeamsPlugin()` | Not available — register services via standard DI | +| `TeamsService` (IHostedService) | Not needed — lifecycle managed by `BotApplication.ProcessAsync()` | +| `AddTeamsTokenAuthentication()` | Built into `AddTeamsBotApplication()` via `BotConfig` | +| `TeamsValidationSettings` | Replaced by `JwtExtensions` + `BotConfig` | +| `AspNetCorePlugin.Configure()` | Use standard `app.UseAuthentication()` / `app.UseAuthorization()` | + +--- + +### Common library replacements + +| Old Type | New Equivalent | +|---|---| +| `Microsoft.Teams.Common.Logging.ILogger` | `Microsoft.Extensions.Logging.ILogger` | +| `Microsoft.Teams.Common.Logging.ConsoleLogger` | `builder.Logging.AddConsole()` | +| `Microsoft.Teams.Common.Logging.LogLevel` | `Microsoft.Extensions.Logging.LogLevel` | +| `Microsoft.Teams.Common.Http.IHttpClient` | `System.Net.Http.HttpClient` via DI | +| `Microsoft.Teams.Common.Http.IHttpClientFactory` | `Microsoft.Extensions.Http.IHttpClientFactory` | +| `Microsoft.Teams.Common.Http.HttpException` | `System.Net.Http.HttpRequestException` | +| `Microsoft.Teams.Common.Storage.IStorage` | No direct replacement — removed from SDK | +| `Microsoft.Teams.Common.Storage.LocalStorage` | No direct replacement — use `IMemoryCache` or custom | + +--- + +### Testing + +The old `TestPlugin` from `Microsoft.Teams.Apps.Testing` is not available. Use standard .NET testing patterns: + +```csharp +// Old: TestPlugin-based +var plugin = new TestPlugin(); +var app = App.Builder().AddPlugin(plugin).Build(); + +// New: Direct instantiation with mocks +var mockBot = new Mock(...); +var context = new Context(mockBot.Object, activity); +``` + +--- + +## Items Under Review + +The following items are being evaluated and may change: + +- **BC-1 (partial):** `Send(AdaptiveCard)` / `Reply(AdaptiveCard)` — pending Teams.Cards dependency decision +- **BC-3:** Middleware / `OnActivity` / `Use()` / `Next()` — architecture review needed +- **BC-11:** `OnSetting()` message extension handler — activity type clarification needed +- **BC-14:** `AddTab()` — scope of feature TBD +- **BC-19:** Missing activity types (`TypingActivity`, `EndOfConversationActivity`, `CommandActivity`) +- **BC-20:** Missing handler registration methods (Tab, Command, Infrastructure, commented-out handlers) +- **BC-22:** `Conversation.ToThreadedConversationId()` static utility +- **BC-23:** MessageActivity commented-out properties (`Speak`, `InputHint`, `Summary`, `Importance`, `DeliveryMode`, `Expiration`) diff --git a/core/docs/ReduceBreakingChangesPlan.md b/core/docs/ReduceBreakingChangesPlan.md new file mode 100644 index 000000000..ff9da2042 --- /dev/null +++ b/core/docs/ReduceBreakingChangesPlan.md @@ -0,0 +1,530 @@ +# Plan: Reduce Breaking Changes between Libraries/Microsoft.Teams.Apps and core/src/Microsoft.Teams.Apps + +## Context + +The `core/src/Microsoft.Teams.Apps` project is the next version of `Libraries/Microsoft.Teams.Apps`. The current samples all target the old library. This plan identifies every public API breaking change and proposes concrete changes to the **new library** to minimize migration friction, prioritized by impact across samples. + +--- + +## Breaking Changes Inventory + +### BC-1: Context convenience methods removed (ALL 13 samples affected) + +**Decision: IMPLEMENT** (defer `Send(AdaptiveCard)` — avoid Teams.Cards dependency for now) + +**Old API:** +```csharp +await context.Send("text", cancellationToken); +await context.Send(card, cancellationToken); +await context.Reply("text", cancellationToken); +await context.Reply(card, cancellationToken); +await context.Typing("processing", cancellationToken); +``` + +**New API:** +```csharp +await context.SendActivityAsync("text", cancellationToken); // only string overload +// No Send(AdaptiveCard), no Reply(), no Typing() +``` + +**Samples affected:** Echo, Cards, Dialogs, Graph, Meetings, MessageExtensions, Reactions, TargetedMessages, Threading, Tab, Lights + +**Proposed fix:** Add convenience methods to `Context`: +- `Send(string text, CancellationToken)` -> wraps `SendActivityAsync(text)` +- `Send(TeamsActivity activity, CancellationToken)` -> wraps `SendActivityAsync(activity)` +- ~~`Send(AdaptiveCard card, CancellationToken)`~~ **DEFERRED** — review later to avoid Teams.Cards dependency +- `Reply(string text, CancellationToken)` -> builds threaded reply activity +- `Reply(TeamsActivity activity, CancellationToken)` -> builds threaded reply +- ~~`Reply(AdaptiveCard card, CancellationToken)`~~ **DEFERRED** — same reason +- `Typing(string? text, CancellationToken)` -> wraps `SendTypingActivityAsync()` + +**File:** `core/src/Microsoft.Teams.Apps/Context.cs` + +--- + +### BC-2: No `context.Log` logger (12 samples affected) + +**Decision: IMPLEMENT** — `context.Log` with `.Info()`, `.Error()`, `.Debug()` delegating to `ILogger` + +**Old API:** +```csharp +context.Log.Info("message"); +context.Log.Error("error"); +context.Log.Debug("debug"); +``` + +**New API:** No logger on context at all. + +**Samples affected:** Echo, Cards, Dialogs, Graph, Meetings, MessageExtensions, Reactions, TargetedMessages, Tab, Lights, BotBuilder, Deprecated.Controllers + +**Proposed fix:** Add `Log` property to `Context` that exposes an object with `.Info()`, `.Error()`, `.Debug()` methods, delegating to `Microsoft.Extensions.Logging.ILogger` sourced from DI. This preserves the old API surface while using the standard logging infrastructure. + +**File:** `core/src/Microsoft.Teams.Apps/Context.cs` + +--- + +### BC-3: No middleware / `OnActivity` + `Use()` + `context.Next()` (5 samples affected) + +**Decision: REVIEW LATER** + +**Old API:** +```csharp +teams.Use(async context => { + // before + await context.Next(); + // after +}); +teams.OnActivity(async (context, cancellationToken) => { + context.Log.Info(context.AppId); + await context.Next(); +}); +``` + +**New API:** No middleware pipeline. Router dispatches directly to matching routes. No `Next()` on context. + +**Samples affected:** Echo, Graph, Meetings, TargetedMessages (OnActivity as catch-all) + +**Note:** Need to investigate whether affected samples use `context.Next()` in a way that requires access to the Teams `Context` inside the middleware. If so, ASP.NET middleware won't be a sufficient replacement. + +**File:** New extension method in `core/src/Microsoft.Teams.Apps/Handlers/` + +--- + +### BC-4: No `context.Ref` (ConversationReference) (2 samples affected) + +**Decision: DOC-ONLY** + +**Old API:** +```csharp +var conversationId = context.Ref.Conversation.Id; +``` + +**New API:** No `Ref` property. Must use `context.Activity.Conversation.Id` directly. + +**Samples affected:** Threading, TargetedMessages + +**Migration:** `context.Ref.Conversation.Id` -> `context.Activity.Conversation.Id` + +--- + +### BC-5: No `context.AppId` (1 sample affected) + +**Decision: IMPLEMENT** + +**Old API:** +```csharp +context.Log.Info(context.AppId); +``` + +**New API:** No `AppId` on context. + +**Samples affected:** Echo + +**Proposed fix:** Add `AppId` property to `Context` reading from `TeamsBotApplication.AppId`. + +**File:** `core/src/Microsoft.Teams.Apps/Context.cs` + +--- + +### BC-6: App.Builder() pattern removed (2 samples affected) + +**Decision: IMPLEMENT** — Add `App.Builder()` as a wrapper around ASP.NET DI + +**Old API:** +```csharp +var appBuilder = App.Builder() + .AddLogger(new ConsoleLogger(...)) + .AddOAuth("graph"); +builder.AddTeams(appBuilder); +``` + +**New API:** +```csharp +builder.Services.AddTeamsBotApplication(options => { + options.AddOAuthFlow("graph"); +}); +``` + +**Samples affected:** Graph, Meetings + +**Proposed fix:** Add `App.Builder()` that wraps the standard ASP.NET DI options pattern, providing a clear migration path from the old builder API. + +--- + +### BC-7: `AddTeams()` extension target changed (ALL samples affected) + +**Decision: IMPLEMENT** — parameterless overload only + +**Old API:** +```csharp +builder.AddTeams(); // on WebApplicationBuilder +builder.AddTeams(appBuilder); // with App.Builder +``` + +**New API:** +```csharp +builder.Services.AddTeams(); // on IServiceCollection +``` + +**Samples affected:** All + +**Proposed fix:** Add parameterless extension method on `WebApplicationBuilder` that delegates to `builder.Services.AddTeams()`. + +**File:** `core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs` + +--- + +### BC-8: Proactive messaging from app-level (1 sample affected) + +**Decision: IMPLEMENT** + +**Old API:** +```csharp +await teams.Send(conversationId, "text", cancellationToken: ct); +await teams.Reply(conversationId, messageId, "text", ct); +``` + +**New API:** No `Send()`/`Reply()` convenience methods on `TeamsBotApplication`. + +**Samples affected:** Threading + +**Proposed fix:** Add convenience methods on `TeamsBotApplication`: +- `Send(string conversationId, string text, ...)` +- `Reply(string conversationId, string messageId, string text, ...)` + +**File:** `core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs` or extension + +--- + +### BC-9: `OnSignIn` / `OnSignInFailure` events removed (1 sample affected) + +**Decision: DOC-ONLY** — existing `context.OnSignIn` methods provide backward compat + +**Old API:** +```csharp +teams.OnSignIn(async (_, @event, cancellationToken) => { ... }); +teams.OnSignInFailure(async (context, cancellationToken) => { ... }); +``` + +**New API:** Uses `OAuthFlow.OnSignInComplete()` and `OAuthFlow.OnSignInFailure()` callbacks. + +**Samples affected:** Graph + +**Migration:** The new pattern uses per-flow callbacks. Existing `context.OnSignIn` methods cover backward compat: +```csharp +var flow = teams.GetOAuthFlow("graph"); +flow.OnSignInComplete(handler); +flow.OnSignInFailure(handler); +``` + +--- + +### BC-10: Meeting handler renames (1 sample affected) + +**Decision: IMPLEMENT** — aliases without `[Obsolete]` + +**Old API:** +```csharp +teams.OnMeetingJoin(handler); +teams.OnMeetingLeave(handler); +``` + +**New API:** +```csharp +teams.OnMeetingParticipantJoin(handler); +teams.OnMeetingParticipantLeave(handler); +``` + +**Samples affected:** Meetings + +**Proposed fix:** Add `OnMeetingJoin()` and `OnMeetingLeave()` as aliases that call the new methods. No `[Obsolete]` attribute for now — will revisit later. + +**File:** `core/src/Microsoft.Teams.Apps/Handlers/MeetingExtensions.cs` + +--- + +### BC-11: `OnSetting()` handler missing (1 sample affected) + +**Decision: REVIEW LATER** + +**Old API:** +```csharp +teams.OnSetting((context, cancellationToken) => { ... }); +``` + +**New API:** No `OnSetting()` extension method. + +**Samples affected:** MessageExtensions + +**Note:** Need to clarify what activity type/invoke name `OnSetting()` matches before implementing. + +**File:** `core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionExtensions.cs` + +--- + +### BC-12: Invoke handler return types changed (4 samples affected) + +**Decision: IMPLEMENT** — factory methods only (no implicit conversions) + +**Old API:** Handlers return library-specific response types: +```csharp +// AdaptiveCardAction returns ActionResponse +return new ActionResponse.Message("text") { StatusCode = 400 }; + +// TaskFetch/Submit returns Microsoft.Teams.Api.TaskModules.Response +return new Microsoft.Teams.Api.TaskModules.Response(...); + +// MessageExtension returns Microsoft.Teams.Api.MessageExtensions.Response +return response; +``` + +**New API:** Handlers return `InvokeResponse` or `InvokeResponse`: +```csharp +Task AdaptiveCardActionHandler(...) +Task> TaskModuleHandler(...) +Task> MessageExtensionQueryHandler(...) +``` + +**Samples affected:** Cards, Dialogs, MessageExtensions, Lights + +**Proposed fix:** Add factory methods: +- `InvokeResponse.Ok(body)` — wraps body with 200 status +- `InvokeResponse.Error(status, body)` — wraps body with error status + +**File:** Invoke response types in core project + +--- + +### BC-13: Activity type hierarchy changed (MEDIUM - affects type usage) + +**Decision: DOC-ONLY** + +**Old:** Activities come from `Microsoft.Teams.Api.Activities` (e.g., `MessageActivity`, `InvokeActivity`) +**New:** Activities come from `Microsoft.Teams.Apps.Schema` (e.g., `MessageActivity : TeamsActivity`) + +**Migration:** Namespace imports change but member access stays the same. Provide namespace mapping table in migration docs. + +--- + +### BC-14: `app.AddTab()` missing (1 sample affected) + +**Decision: REVIEW LATER** + +**Old API:** +```csharp +app.AddTab("dialog-form", "Web/dialog-form"); +``` + +**New API:** No `AddTab()` method. + +**Samples affected:** Dialogs, Tab + +**Note:** Need to determine if `AddTab()` is just static file serving or also registers Teams tab config endpoints. This affects whether a simple "use `app.UseStaticFiles()`" migration note is sufficient. + +--- + +## Sample Migration Difficulty Assessment + +| Sample | Difficulty | Key Blockers | +|--------|-----------|-------------| +| Samples.Echo | **Easy** | BC-1 (Send/Typing), BC-2 (Log), BC-3 (OnActivity/Next), BC-5 (AppId), BC-7 (AddTeams) | +| Samples.Cards | **Easy** | BC-1 (Send), BC-2 (Log), BC-7, BC-12 (InvokeResponse) | +| Samples.Reactions | **Easy** | BC-1 (Send), BC-2 (Log), BC-7 | +| Samples.Threading | **Easy** | BC-1 (Send/Reply), BC-4 (Ref), BC-7, BC-8 (proactive) | +| Samples.Dialogs | **Easy-Med** | BC-1 (Send), BC-2 (Log), BC-7, BC-12, BC-14 (AddTab) | +| Samples.MessageExtensions | **Easy-Med** | BC-1, BC-2, BC-7, BC-11 (OnSetting), BC-12 | +| Samples.TargetedMessages | **Easy-Med** | BC-1 (Send/Reply), BC-2, BC-3 (OnActivity), BC-7 | +| Samples.Meetings | **Medium** | BC-1, BC-2, BC-3 (Use/Next), BC-6 (Builder), BC-7, BC-10 (renames) | +| Samples.Graph | **Medium-Hard** | BC-1, BC-2, BC-3 (Use/Next), BC-6 (Builder), BC-7, BC-9 (SignIn events) | +| Samples.BotBuilder | **Hard** | Entire `AddBotBuilder<>()` pattern missing | +| Deprecated.Controllers | **N/A** | Already deprecated, no migration needed | + +--- + +## Implementation Plan (ordered by impact) + +### Item 1: Add `Send()`, `Reply()`, `Typing()` convenience methods to Context +- **File:** `core/src/Microsoft.Teams.Apps/Context.cs` +- **Impact:** Resolves BC-1, unblocks ALL samples +- **Details:** Add methods matching old signatures (except AdaptiveCard overloads — deferred). `Reply()` builds a threaded reply using `Activity.Conversation.Id` and `Activity.Id`. + +### Item 2: Add `context.Log` with `.Info()`, `.Error()`, `.Debug()` delegating to ILogger +- **File:** `core/src/Microsoft.Teams.Apps/Context.cs` +- **Impact:** Resolves BC-2, unblocks 12 samples +- **Details:** Expose a `Log` property with `.Info()`, `.Error()`, `.Debug()` methods that delegate to `Microsoft.Extensions.Logging.ILogger` from DI. Preserves old API surface. + +### Item 3: Add `WebApplicationBuilder.AddTeams()` extension +- **File:** `core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs` +- **Impact:** Resolves BC-7, unblocks ALL samples +- **Details:** Parameterless `public static IServiceCollection AddTeams(this WebApplicationBuilder builder) => builder.Services.AddTeams();` + +### Item 4: Add `AppId` property to Context +- **File:** `core/src/Microsoft.Teams.Apps/Context.cs` +- **Impact:** Resolves BC-5 +- **Details:** `AppId` sourced from `TeamsBotApplication.AppId`. + +### Item 5: Add `App.Builder()` wrapper around ASP.NET DI +- **Impact:** Resolves BC-6, unblocks Graph and Meetings samples +- **Details:** `App.Builder()` returns a builder that wraps standard ASP.NET DI options pattern. + +### Item 6: Add `OnMeetingJoin`/`OnMeetingLeave` aliases +- **File:** `core/src/Microsoft.Teams.Apps/Handlers/MeetingExtensions.cs` +- **Impact:** Resolves BC-10 +- **Details:** Aliases to `OnMeetingParticipantJoin`/`OnMeetingParticipantLeave`. No `[Obsolete]` for now. + +### Item 7: Add proactive `Send()`/`Reply()` on TeamsBotApplication +- **File:** `core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs` or extension +- **Impact:** Resolves BC-8, unblocks Threading sample + +### Item 8: Add `InvokeResponse.Ok()`/`InvokeResponse.Error()` factory methods +- **Impact:** Resolves BC-12 +- **File:** Invoke response types in core project + +### Item 9: Document migration for architectural changes (no code) +- `context.Ref` (BC-4): `context.Ref.Conversation.Id` -> `context.Activity.Conversation.Id` +- SignIn events (BC-9): `teams.OnSignIn()` -> `flow.OnSignInComplete()` (existing context.OnSignIn covers compat) +- Namespace changes (BC-13): Provide mapping table + +--- + +## API Surface Gaps (from systematic comparison) + +These were identified by comparing the full public API surface between old and new libraries, beyond what the sample-driven analysis caught. + +### BC-15: MessageActivity fluent methods removed + +**Decision: IMPLEMENTED** — Extension methods added in `MessageActivityExtensions.cs` + +Methods added: `WithText()`, `WithSuggestedActions()`, `WithTextFormat()`, `WithAttachmentLayout()`, `AddAttachment()`, `AddStreamFinal()`. + +Not migrated (low priority, underlying properties commented out): `WithSpeak()`, `WithInputHint()`, `WithSummary()`, `WithImportance()`, `WithDeliveryMode()`, `WithExpiration()`, `AddText()`, `Merge()`. + +--- + +### BC-16: `AddSensitivityLabel()` missing on TeamsActivity + +**Decision: IMPLEMENTED** — Extension method added in `ActivityCitationExtensions`. + +--- + +### BC-17: Base Activity fluent `With*()` methods removed + +**Decision: NOT MIGRATED** — The old `Activity` base class had 13+ `With*()` methods (`WithId`, `WithFrom`, `WithRecipient`, `WithConversation`, `WithServiceUrl`, `WithTimestamp`, etc.). These are all available on `TeamsActivityBuilder`. Use the builder pattern instead: + +```csharp +// Old: +var activity = new Activity().WithFrom(account).WithConversation(conv); + +// New: +var activity = new TeamsActivityBuilder() + .WithFrom(account) + .WithConversation(conv) + .Build(); +``` + +--- + +### BC-18: Activity conversion methods removed (`ToMessage()`, `ToInvoke()`, etc.) + +**Decision: NOT MIGRATED** — The old library had `ToMessage()`, `ToInvoke()`, `ToEvent()`, etc. The new library uses `FromActivity()` static factory methods instead: + +```csharp +// Old: activity.ToMessage() +// New: MessageActivity.FromActivity(coreActivity) +``` + +--- + +### BC-19: Missing activity types + +**Decision: REVIEW LATER** + +| Missing Type | Notes | +|---|---| +| `TypingActivity` | No class in new lib; typing handled via `TeamsActivityType.Typing` | +| `EndOfConversationActivity` | Commented out / TODO | +| `CommandActivity` / `CommandResultActivity` | Commented out / TODO | +| `ConversationReference` | Entire class missing; no direct replacement | + +--- + +### BC-20: Missing handler registration methods + +**Decision: REVIEW LATER** — 18 handler methods exist in the old library but not in the new. + +**Tab handlers (completely removed):** +- `OnTabFetch`, `OnTabSubmit`, `OnConfigFetch`, `OnConfigSubmit` + +**Command handlers (removed):** +- `OnCommand`, `OnCommandResult` + +**Infrastructure events (architectural change):** +- `OnActivity`, `OnError`, `OnStart`, `OnActivityResponse`, `OnActivitySent` + +**Auth events (restructured to per-flow):** +- `OnSignIn`, `OnSignInFailure`, `OnTokenExchange`, `OnVerifyState` + +**Other removed handlers:** +- `OnTyping`, `OnHandoff`, `OnFeedback`, `OnExecuteAction` + +**Commented out in new library:** +- `OnSetting`, `OnCardButtonClicked`, `OnTypeaheadSearch`, `OnAnswerSearch`, `OnReadReceipt` + +--- + +### BC-21: Type incompatibilities + +**Decision: NOT MIGRATED** — Intentional architectural changes. + +| Property | Old Type | New Type | +|---|---|---| +| `Timestamp`, `LocalTimestamp` | `DateTime?` | `string?` | +| `ServiceUrl` | `string?` | `Uri?` | +| `ContentUrl`, `ThumbnailUrl` (Attachment) | `string?` | `Uri?` | +| Enums (`TextFormat`, `InputHint`, etc.) | Enum types | String constants | +| `Account` | Custom `Account` class | `ConversationAccount` | + +--- + +### BC-22: `Conversation.ToThreadedConversationId()` missing + +**Decision: REVIEW LATER** — Static utility method for constructing threaded conversation IDs. Used by Threading sample. The new `TeamsBotApplication.Reply()` handles this internally, but direct usage in sample code would break. + +--- + +### BC-23: MessageActivity commented-out properties + +**Decision: REVIEW LATER** — These properties exist in the old library but are commented out in the new: +`Speak`, `InputHint`, `Summary`, `Importance`, `DeliveryMode`, `Expiration`, `Value` + +--- + +### BC-24: SuggestedActions fluent methods removed + +**Decision: NOT MIGRATED** — Old `SuggestedActions` had `AddRecipients()`, `AddAction()`, `AddActions()` fluent methods. Use direct property assignment instead. + +--- + +## Items to Review Later + +- **BC-1 (partial):** `Send(AdaptiveCard)` / `Reply(AdaptiveCard)` — blocked on Teams.Cards dependency decision +- **BC-3:** Middleware / `OnActivity` / `Use()` / `Next()` — need to investigate sample usage patterns +- **BC-11:** `OnSetting()` handler — need to clarify activity type/invoke name +- **BC-14:** `AddTab()` — need to determine if it's static files only or also tab config endpoints +- **BC-19:** Missing activity types (`TypingActivity`, `EndOfConversationActivity`, `CommandActivity`) +- **BC-20:** Missing handler registration methods (Tab, Command, Infrastructure, commented-out) +- **BC-22:** `Conversation.ToThreadedConversationId()` static utility +- **BC-23:** MessageActivity commented-out properties + +--- + +## Verification + +After each item: +1. Build `core/src/Microsoft.Teams.Apps` to verify compilation +2. Verify existing tests pass (if any) +3. For each item, attempt to mentally trace migration of affected sample code to confirm the gap is closed + +After all items: +1. Migrate Samples.Echo as proof-of-concept to validate the full compat surface +2. Run the migrated sample to verify end-to-end functionality diff --git a/core/docs/design-decouple-cancellation-token.md b/core/docs/design-decouple-cancellation-token.md new file mode 100644 index 000000000..00fc2e2c7 --- /dev/null +++ b/core/docs/design-decouple-cancellation-token.md @@ -0,0 +1,104 @@ +# Design: Decouple CancellationToken from Incoming HTTP Request + +## Problem + +When a bot handler performs long-running work — most notably streaming LLM responses back to Teams — the `CancellationToken` passed into the handler is tied to the lifetime of the **incoming HTTP request** (`HttpContext.RequestAborted`). Teams closes that connection once it receives the initial HTTP response (typically within ~15 seconds), which fires the cancellation token and aborts any in-flight outbound calls the handler is still making. + +### Observed behavior + +``` +dbug: HTTP POST .../v3/conversations/.../activities/... Response Status 202 +fail: Error processing activity: Id=... + System.Threading.Tasks.TaskCanceledException: The operation was canceled. + ---> System.IO.IOException: Unable to read data from the transport connection: + The I/O operation has been aborted because of either a thread exit or an application request. +``` + +The exception propagates through the OpenAI streaming pipeline, through `BotApplication.ProcessAsync`, and surfaces as a 500 to the ASP.NET middleware — even though the bot was functioning correctly. + +### Why this matters + +Streaming bots send responses via the **Bot Connector API** (`ConversationClient.SendActivityAsync`), not through the original HTTP response body. The handler legitimately outlives the HTTP request, so cancellation of that request should **not** cancel the handler's work. + +## Solution + +Replace the HTTP-bound `CancellationToken` with a **configurable timeout-based token** inside `BotApplication.ProcessAsync`. + +### Changes + +#### 1. `BotApplicationOptions.ProcessActivityTimeout` + +A new property on `BotApplicationOptions`: + +```csharp +public TimeSpan ProcessActivityTimeout { get; set; } = TimeSpan.FromMinutes(5); +``` + +- **Default: 5 minutes** — long enough for streaming LLM responses, short enough to prevent runaway handlers. +- Set to `Timeout.InfiniteTimeSpan` to disable the timeout entirely. +- Configurable per application instance via DI / builder options. + +#### 2. `BotApplication.ProcessAsync` — token replacement + +Before this change: + +```csharp +CancellationToken token = Debugger.IsAttached ? CancellationToken.None : cancellationToken; +await MiddleWare.RunPipelineAsync(this, activity, this.OnActivity, 0, token); +``` + +After: + +```csharp +using var cts = new CancellationTokenSource(_processActivityTimeout); +CancellationToken token = Debugger.IsAttached ? CancellationToken.None : cts.Token; +await MiddleWare.RunPipelineAsync(this, activity, this.OnActivity, 0, token); +``` + +The HTTP request's `cancellationToken` is no longer forwarded to the handler pipeline. + +#### 3. Graceful timeout handling + +A new catch clause handles the timeout without crashing: + +```csharp +catch (OperationCanceledException) when (cts.IsCancellationRequested) +{ + _logger.LogWarning("Activity processing timed out after {Timeout}: Id={Id}", + _processActivityTimeout, activity.Id); +} +``` + +This prevents `BotHandlerException` from being thrown when the timeout fires, which is a recoverable situation (the handler simply took too long). + +## Design Decisions + +### Why not keep the HTTP token as a linked source? + +Using `CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)` would still propagate HTTP disconnection to the handler — defeating the purpose. The HTTP request completing is an **expected** event for streaming handlers, not an error signal. + +### Why a timeout instead of `CancellationToken.None`? + +Unbounded processing is a resource leak risk. A timeout provides a safety net: +- Prevents handlers from running indefinitely if the LLM or external service hangs. +- Gives operators a tuning knob via `ProcessActivityTimeout`. +- Preserves the existing `Debugger.IsAttached` → `CancellationToken.None` escape hatch for debugging. + +### Why handle this at the framework level? + +- Every streaming bot would need the same workaround in user code. +- The framework owns the token plumbing and is the right place to define its semantics. +- Non-streaming bots are unaffected — 5 minutes is generous for synchronous handlers and can be reduced via options. + +### Impact on non-streaming bots + +Non-streaming handlers that complete within the HTTP request lifetime are unaffected. The 5-minute default is well above typical synchronous handler durations. Apps that want tighter timeouts can set `ProcessActivityTimeout` to a lower value. + +## Alternatives Considered + +| Alternative | Drawback | +|---|---| +| Catch `TaskCanceledException` in each sample/handler | Pushes framework responsibility to every consumer; easy to forget | +| Use `CancellationToken.None` unconditionally | No timeout safety net; runaway handlers can leak resources | +| Expose a `bool IsStreaming` flag to switch behavior | Over-engineered; all handlers benefit from decoupling | +| Let ASP.NET Core's `RequestTimeout` middleware handle it | That controls the *HTTP* timeout, not the *handler processing* timeout — different concerns | diff --git a/core/docs/sso/OAuthFlow-Design.md b/core/docs/sso/OAuthFlow-Design.md new file mode 100644 index 000000000..b0d8c4e1b --- /dev/null +++ b/core/docs/sso/OAuthFlow-Design.md @@ -0,0 +1,700 @@ +# OAuthFlow Design Document + +## Overview + +`OAuthFlow` provides a high-level abstraction for Teams Bot SSO (Single Sign-On) authentication. It encapsulates the full OAuth lifecycle -- silent token acquisition, SSO token exchange, fallback sign-in, and sign-out -- so developers can add user authentication with minimal plumbing. + +The design builds on top of the existing `UserTokenClient` (core) and `UserTokenApiClient` / `BotSignInClient` (Apps layer), and follows the handler-based routing pattern established by `AdaptiveCardExtensions`, `TaskExtensions`, etc. + +## Motivation + +Teams SSO requires coordinating multiple moving parts: + +1. Checking the Bot Framework Token Store for an existing token +2. Sending an OAuthCard with a `TokenExchangeResource` to trigger silent SSO +3. Handling `signin/tokenExchange` invoke activities (with deduplication) +4. Handling `signin/verifyState` invoke activities (fallback sign-in flow) +5. Handling `signin/failure` invoke activities (client-side SSO failures) +6. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange + +Without an abstraction, every bot developer must wire this up manually. `OAuthFlow` reduces it to a few method calls. + +## Architecture + +``` +TeamsBotApplication +├── AppId ← from BotConfig.ClientId +├── OAuthRegistry ← holds all OAuthFlow instances +├── Router +│ ├── ... existing routes ... +│ ├── invoke/signin/tokenExchange ← registered by OAuthFlow +│ ├── invoke/signin/verifyState ← registered by OAuthFlow +│ └── invoke/signin/failure ← registered by OAuthFlow (client-side SSO failures) +└── OAuthFlow (one per connection) + ├── SignInAsync() → silent token check + OAuthCard + ├── SignOutAsync() → revoke token + ├── IsSignedInAsync() → check token store + ├── GetTokenAsync() → silent-only token retrieval + ├── OnSignInComplete() → callback after successful exchange + └── OnSignInFailure() → callback on exchange failure +``` + +### Two API Layers + +Developers can use **either** the context-level API (simple, matches Teams SDK v2 pattern) or the OAuthFlow-instance API (advanced, explicit per-connection control): + +| Scenario | Context API (simple) | OAuthFlow API (advanced) | +|---|---|---| +| Sign in | `context.SignIn(new OAuthOptions { ConnectionName = "gh" })` | `githubAuth.SignInAsync(context)` (uses options from `AddOAuthFlow`) | +| Sign out | `context.SignOut("gh")` | `githubAuth.SignOutAsync(context)` | +| Check status | `context.IsSignedInAsync("gh")` | `githubAuth.IsSignedInAsync(context)` | +| All connections | `context.GetConnectionStatusAsync()` | `graphAuth.GetConnectionStatusAsync(context)` | +| Single connection | `context.SignIn()` / `context.IsSignedIn` | `auth.SignInAsync(context)` | + +### Relationship to existing clients + +``` +OAuthFlow (Apps layer - developer-facing) + │ + ├── UserTokenClient.GetTokenAsync() → silent token check + ├── UserTokenClient.ExchangeTokenAsync() → SSO token exchange + ├── UserTokenClient.GetTokenStatusAsync() → connection discovery & status + ├── UserTokenClient.SignOutUserAsync() → sign-out + └── UserTokenClient.GetSignInResourceAsync() → sign-in resource (OAuthCard data) +``` + +`OAuthFlow` does **not** replace these clients. It orchestrates them into a cohesive flow and auto-registers the invoke handlers that the SSO protocol requires. + +## Breaking Changes from Teams SDK v2 (Spark) + +This section documents every API and behavioral difference between the old `Context` (in `Microsoft.Teams.Apps`) and the new `Context` (in `Microsoft.Teams.Apps`) related to SSO/Auth. + +### 1. `context.ConnectionName` removed + +**Old (v2)**: `Context` has a `required string ConnectionName` property that holds the app's default connection name (set during context construction, defaults to `"graph"`). `SignIn()` and `SignOut()` fall back to this when no explicit connection name is given. + +**New**: No `ConnectionName` property on context. The default connection is resolved from the `OAuthFlowRegistry` -- if a single `OAuthFlow` is registered, it is used as the default. If multiple are registered, the developer must specify the connection name per-call. + +```csharp +// Old (v2) -- default connection baked into context +await context.SignIn(); // uses context.ConnectionName ("graph") + +// New -- resolved from OAuthFlowRegistry +bot.AddOAuthFlow("graph"); // single flow → becomes the default +await context.SignIn(); // works (single flow auto-resolves) + +// New -- multiple flows with options configured at registration +var ghAuth = bot.AddOAuthFlow(new OAuthOptions +{ + ConnectionName = "gh", + OAuthCardText = "Sign in to GitHub", + SignInButtonText = "Sign In" +}); +await ghAuth.SignInAsync(context); // uses options from registration +``` + +**Migration**: Replace reads of `context.ConnectionName` with the explicit connection name in `OAuthOptions` or `SignOut(connectionName)`. + +### 2. `context.IsSignedIn` semantics changed + +**Old (v2)**: `IsSignedIn` is a read/write `bool` property (`{ get; set; }`). It is set to `true` by the framework when a `signin/tokenExchange` invoke completes successfully during the current turn. It is a **per-turn flag**, not a token-store query. It reflects whether the sign-in **just happened** in this turn, not whether a token exists in the store. + +**New**: `IsSignedIn` is a read-only `bool` property that **synchronously queries the token store** (`GetAwaiter().GetResult()`). It checks whether the user has a cached token right now, regardless of what happened during this turn. It cannot be set by the developer. + +| | Old (v2) | New | +|---|---|---| +| Type | `bool { get; set; }` | `bool { get; }` | +| Source of truth | Framework sets it during the turn | Queries token store on each access | +| Async | No (already computed) | No (sync-over-async) | +| Multi-connection | N/A (one default connection) | Checks first registered flow, logs warning if multiple | +| Writable | Yes | No | + +**Recommended migration**: Use `IsSignedInAsync(connectionName?)` for async, connection-aware checks: + +```csharp +// Old (v2) +if (!context.IsSignedIn) { await context.SignIn(); return; } + +// New (preferred) +if (!await context.IsSignedInAsync("graph", ct)) { await context.SignIn(new OAuthOptions { ConnectionName = "graph" }, ct); return; } + +// New (backwards-compat, single connection only) +if (!context.IsSignedIn) { await context.SignIn(ct); return; } +``` + +### 3. `context.UserGraphToken` removed + +**Old (v2)**: `Context` has a `JsonWebToken? UserGraphToken` property set by the framework's `OnTokenExchangeActivity` handler after a successful token exchange. It provides parsed JWT access to the Graph token (claims, expiry, etc.). + +**New**: No `UserGraphToken` property. The token is returned as a raw `string` from `SignIn()` / `GetTokenAsync()` / `OnSignInComplete`. If JWT parsing is needed, the developer must parse it themselves. + +```csharp +// Old (v2) +var graphClient = new SimpleGraphClient(context.UserGraphToken?.ToString()!); + +// New +string? token = await context.SignIn(new OAuthOptions { ConnectionName = "graph" }, ct); +var graphClient = new SimpleGraphClient(token!); +``` + +### 4. `context.SignIn(SSOOptions)` overload removed + +**Old (v2)**: Two `SignIn` overloads exist: +- `SignIn(OAuthOptions?)` -- OAuth flow via Bot Framework Token Service +- `SignIn(SSOOptions)` -- Direct SSO flow with custom scopes and sign-in link (bypasses Token Service, constructs its own `TokenExchangeResource`) + +**New**: Only `SignIn(OAuthOptions?)` is available. The SSO flow is handled transparently when the OAuth connection is configured as Azure AD v2 -- the `TokenExchangeResource` is returned by the Token Service when `MsAppId` is included in the state. + +**Migration**: Remove `SSOOptions` usage. Configure the OAuth connection in Azure Bot settings with the appropriate scopes. The `OAuthFlow` handles SSO automatically for Azure AD connections. + +### 5. `context.SignIn()` return type is the same but semantics differ + +**Old (v2)**: `SignIn(OAuthOptions?)` returns `Task`. Returns the cached token if found, otherwise sends OAuthCard and returns `null`. The `SignIn(SSOOptions)` overload returns `Task` (void). + +**New**: `SignIn(OAuthOptions?)` returns `Task` with the same semantics -- token if cached, `null` if OAuthCard sent. No void overload. + +This is **API-compatible** for the `OAuthOptions` overload. Breaking only for `SSOOptions` users. + +### 6. `OnSignInComplete` callback signature + +**Old (v2)**: Sign-in success is delivered via an app-level event: +```csharp +// Old (v2) +teams.OnSignIn(async (plugin, @event, cancellationToken) => { + var token = @event.Token; // Token.Response object + var context = @event.Context; // IContext +}); +``` + +**New**: Sign-in success is delivered via a per-connection callback: +```csharp +// New +graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { + string token = tokenResponse.Token!; // GetTokenResult + // context is Context (base type) +}); +``` + +Key differences: +- **Scope**: Old is app-level (one handler for all connections). New is per-connection. +- **Context type**: Old provides `IContext`. New provides `Context` because the sign-in can complete from invoke (tokenExchange, verifyState) activities. +- **Token type**: Old provides `Token.Response` (with `ConnectionName`, `Token`, `Expiration`, `Properties`). New provides `GetTokenResult` (with `ConnectionName`, `Token`). +- **Plugin parameter**: Old receives the plugin instance. New does not -- the context has access to `TeamsBotApplication`. + +### 7. `OnSignInFailure` callback signature and scope + +**Old (v2)**: App-level handler receiving the failure activity: +```csharp +// Old (v2) +teams.OnSignInFailure(async (context, cancellationToken) => { + var failure = context.Activity.Value; // SignIn.Failure { Code, Message } + await context.Send("Sign-in failed.", cancellationToken); +}); +``` + +**New**: Per-connection handler on the `OAuthFlow` instance: +```csharp +// New +graphAuth.OnSignInFailure(async (context, failure, ct) => { + // context is Context + // failure is non-null for signin/failure invokes (client-side SSO errors) + if (failure is not null) + await context.SendActivityAsync($"Sign-in failed: {failure.Code} — {failure.Message}", ct); + else + await context.SendActivityAsync("Sign-in failed.", ct); +}); +``` + +Key differences: +- **Scope**: Per-connection instead of app-level. +- **Failure details**: Old provides `SignIn.Failure` with `Code` and `Message` via the activity value. New provides `SignInFailureValue?` — non-null with structured `Code`/`Message` for `signin/failure` invokes (client-side SSO errors), null for server-side token exchange or verify-state failures. +- **`context.Send` → `context.SendActivityAsync`**: Method name change (see below). + +### 8. `context.Send()` → `context.SendActivityAsync()` + +**Old (v2)**: `context.Send(string)` and `context.Send(T activity)`. + +**New**: `context.SendActivityAsync(string)` and `context.SendActivityAsync(TeamsActivity)`. + +This affects all code inside `OnSignInComplete` and `OnSignInFailure` callbacks. + +### 9. Group chat handling removed from `SignIn` + +**Old (v2)**: `Context.SignIn()` detects group chats (`Activity.Conversation.IsGroup == true`) and automatically creates a 1:1 conversation with the user before sending the OAuthCard, because group chats don't support SSO. + +**New**: `OAuthFlow.SignInAsync()` does not handle the group-chat-to-1:1 conversion. The OAuthCard is sent to the current conversation. For group chats, the sign-in card will show the button (no SSO), but the popup flow still works. + +**Migration**: If group chat SSO is required, the developer must create the 1:1 conversation manually before calling `context.SignIn()`. + +### 10. `OAuthOptions` namespace and defaults + +| | Old (v2) | New | +|---|---|---| +| Namespace | `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Auth` | +| Base class | `SignInOptions` (abstract) | None (standalone class) | +| `OAuthCardText` default | `"Please Sign In..."` | `"Please Sign In"` | +| `SignInButtonText` default | `"Sign In"` | `"Sign In"` | +| `ConnectionName` | Falls back to `context.ConnectionName` | Falls back to single registered `OAuthFlow` | + +### 11. `SSOOptions` class removed + +**Old (v2)**: `SSOOptions : SignInOptions` with `required string[] Scopes` and `required string SignInLink`. + +**New**: Not available. SSO is handled automatically for Azure AD connections via the `TokenExchangeResource` mechanism. + +### 12. No `context.Next()` equivalent in auth handlers + +**Old (v2)**: `context.Next()` continues the middleware/route chain. The `OnSignIn` event handler can call `context.Next()` to continue processing. + +**New**: `OnSignInComplete` and `OnSignInFailure` are terminal callbacks, not middleware. They do not participate in the route chain. + +### 13. Automatic user token retrieval on every activity removed + +**Old (v2)**: `App.Process()` (App.cs:299-311) silently calls `api.Users.Token.GetAsync()` for **every** inbound activity, using `OAuth.DefaultConnectionName` (defaults to `"graph"`). If a token exists, it sets `context.IsSignedIn = true` and populates `context.UserGraphToken`. If the call fails, the exception is silently swallowed. This means `IsSignedIn` is always pre-populated by the time the developer's handler runs, even if no OAuth flow was configured. + +**New**: No automatic token retrieval. `IsSignedIn` and `GetTokenAsync` are only called when the developer explicitly invokes them. There is no implicit per-turn token check. + +**Impact**: Old code that relied on `context.IsSignedIn` being `true` on the first message (without calling `SignIn()`) must now explicitly call `await context.IsSignedInAsync()` or `await context.SignIn()` to check for a cached token. + +### 14. Bot token retrieval on startup removed + +**Old (v2)**: `App.Start()` (App.cs:130-141) eagerly calls `Api.Bots.Token.GetAsync(Credentials, TokenClient)` to obtain the bot's own access token at startup. If the call fails, it logs `"Failed to get bot token on app startup."` and continues (non-fatal). A lazy `TokenFactory` (App.cs:64-90) also refreshes the bot token on demand when it expires. + +**New**: Bot-to-service authentication is handled at the Core level (`BotApplication` / `BotConfig.ClientId`) and does not surface in the OAuthFlow layer. There is no explicit bot token fetch on startup in the Apps layer. + +**Impact**: No developer action required -- this is an internal framework change. + +### 15. No deduplication in old SDK + +**Old (v2)**: The `OnTokenExchangeActivity` handler (AppRouting.cs:69-127) has **no deduplication logic**. Every `signin/tokenExchange` invoke triggers a token exchange call to the Token Service. Duplicate exchanges from multiple Teams endpoints (mobile, desktop, web) all hit the Token Service independently. The `OnSignIn` event fires for each. + +**New**: `OAuthFlow` deduplicates `signin/tokenExchange` by exchange ID using an in-process `ConcurrentDictionary` with a 5-minute TTL. Duplicates receive a `200` no-op response without calling the Token Service or firing callbacks. + +**Impact**: Old code that observed multiple `OnSignIn` events per sign-in (one per endpoint) will now only see `OnSignInComplete` fire once (per instance). Handlers that were designed to be idempotent to tolerate duplicates will still work. + +### 16. `signin/failure` invoke handler — now registered (parity achieved) + +**Old (v2)**: `OnSignInFailureActivity` (AppRouting.cs:182-225) handles the `signin/failure` invoke sent by the Teams client when SSO fails. It parses 9 documented failure codes: +- `installappfailed`, `authrequestfailed`, `installedappnotfound`, `invokeerror`, `resourcematchfailed`, `oauthcardnotvalid`, `tokenmissing`, `userconsentrequired`, `interactionrequired` + +Each failure is logged with the user ID, conversation ID, failure code, and message. The handler returns `200` to acknowledge. The `OnSignInFailure` app-level event fires with the structured failure details. + +**New**: A `signin/failure` invoke handler is registered automatically by `AddOAuthFlow`. It logs the failure code and message (with extra guidance for `resourcematchfailed`), then fires the `OnSignInFailure` callback on **all** registered flows (since the invoke carries no connection name). The `SignInFailureHandler` delegate receives a `SignInFailureValue?` parameter containing the structured `Code` and `Message` from the Teams client. + +**Differences from v2**: +- **Scope**: Per-connection `OnSignInFailure` callback (fired on all flows) instead of a single app-level event. +- **Delegate signature**: `SignInFailureHandler(Context, SignInFailureValue?, CancellationToken)`. The `SignInFailureValue` parameter is non-null for `signin/failure` invokes and null for server-side token exchange / verify-state failures. + +### 17. Token exchange error response mapping — now matches v2 (parity achieved) + +**Old (v2)**: The `OnTokenExchangeActivity` handler (AppRouting.cs:102-127) catches `HttpException` and maps error codes selectively: +- `NotFound`, `BadRequest`, `PreconditionFailed` → responds with `PreconditionFailed` (412) and `TokenExchange.InvokeResponse` body containing `Id`, `ConnectionName`, `FailureDetail` +- All other status codes (e.g., `Unauthorized`, `Forbidden`) → responds with the **original** HTTP status code + +**New**: `OAuthFlow.HandleTokenExchangeAsync` now uses the same selective mapping: +- `NotFound`, `BadRequest`, `PreconditionFailed` (or null status code) → responds with `InvokeResponse(412)` and a `TokenExchangeInvokeResponse` body containing `Id`, `ConnectionName`, `FailureDetail` +- All other status codes → responds with the **original** HTTP status code + +**Differences from v2**: +- `FailureDetail` contains `ex.Message` (concise) instead of `ex.ToString()` (full stack trace). This avoids leaking internal implementation details in the invoke response while still providing diagnostic information. + +### 18. `signin/verifyState` error response — now matches v2 (parity achieved) + +**Old (v2)**: The `OnVerifyStateActivity` handler (AppRouting.cs:129-180): +- Missing `State` parameter → returns `NotFound` (404) with a log warning +- Token exchange failure (`NotFound`, `BadRequest`, `PreconditionFailed`) → returns `PreconditionFailed` (412) +- Other HTTP errors → returns the original status code + +**New**: `OAuthFlow.HandleVerifyStateAsync` now uses the same error mapping: +- Null invoke payload → returns `404` (at route level) +- Null `State` parameter → returns `404` with a log warning +- No token returned → returns `412` +- HTTP failure (`NotFound`, `BadRequest`, `PreconditionFailed`) → returns `412` +- Other HTTP errors → returns the original status code +- No registered flow matched → returns `404` + +### Summary Table + +| Feature | Old (v2) `Microsoft.Teams.Apps` | New `Microsoft.Teams.Apps` | Breaking? | +|---|---|---|---| +| `context.ConnectionName` | `required string` property | Removed (resolved from registry) | Yes | +| `context.IsSignedIn` | `bool { get; set; }` (per-turn flag) | `bool { get; }` (queries token store) | Yes (semantic) | +| `context.UserGraphToken` | `JsonWebToken?` property | Removed | Yes | +| `context.SignIn(OAuthOptions?)` | Returns `Task` | Returns `Task` | No | +| `context.SignIn(SSOOptions)` | Returns `Task` | Removed | Yes | +| `context.SignOut(string?)` | Returns `Task` | Returns `Task` | No | +| `OnSignIn` event | App-level, `SignInEvent` | Per-connection `OnSignInComplete` | Yes | +| `OnSignInFailure` event | App-level, `SignIn.Failure` | Per-connection `OnSignInFailure` | Yes | +| `OAuthOptions` namespace | `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Auth` | Yes | +| `SSOOptions` | Available | Removed | Yes | +| Group chat 1:1 fallback | Automatic | Manual | Yes (behavioral) | +| `context.Send()` | Available | `context.SendActivityAsync()` | Yes (rename) | +| `context.Next()` in auth | Available | Not applicable | Yes | +| `IsSignedInAsync()` | Not available | New method | N/A (addition) | +| `GetConnectionStatusAsync()` | Not available | New method | N/A (addition) | +| User token pre-fetch per activity | Automatic (silent, every turn) | On-demand only | Yes (behavioral) | +| Bot token fetch on startup | `App.Start()` fetches eagerly | Handled at Core level | No (internal) | +| Token exchange deduplication | None (every invoke hits Token Service) | `ConcurrentDictionary` by exchange ID, 5-min TTL | Yes (behavioral) | +| `signin/failure` invoke | App-level handler, 9 failure codes | Per-connection `OnSignInFailure` with `SignInFailureValue` | No (parity) | +| Token exchange error response | 412 + body for expected, original for others | 412 + `TokenExchangeInvokeResponse` for expected, original for others | No (parity) | +| `signin/verifyState` error response | 404 (missing state), 412 (exchange failure) | 404 (missing state), 412 (exchange failure) | No (parity) | + +## API Surface + +### Registration (DI pattern — recommended) + +```csharp +// Configure OAuth flows during service registration +services.AddTeamsBotApplication(options => +{ + options.AddOAuthFlow("GraphConnection", o => + { + o.OAuthCardText = "Sign in to your Microsoft account"; + o.SignInButtonText = "Sign In to Graph"; + }); + options.AddOAuthFlow("GitHubConnection"); // uses defaults +}); + +// Flows are auto-registered when the bot is constructed. +// Access them for callbacks: +TeamsBotApplication bot = app.UseTeamsBotApplication(); +bot.GetOAuthFlow("GraphConnection").OnSignInComplete(async (ctx, token, ct) => { ... }); +``` + +### Registration (imperative — on the bot instance) + +```csharp +public static class OAuthFlowExtensions +{ + /// Register an OAuthFlow with an explicit connection name (uses default OAuthCard text). + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName); + + /// Register an OAuthFlow with OAuthOptions that configure the connection name + /// and default OAuthCard text. Per-call options override these defaults. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, OAuthOptions options); +} +``` + +Both approaches register three routes on the app's `Router`: + +| Route name | Activity type | Purpose | +|---|---|---| +| `invoke/signin/tokenExchange` | Invoke | SSO silent token exchange | +| `invoke/signin/verifyState` | Invoke | Fallback sign-in verification | +| `invoke/signin/failure` | Invoke | Teams client-side SSO failure notification | + +### Context Methods + +```csharp +public class Context where TActivity : TeamsActivity +{ + /// Trigger sign-in flow. Returns cached token or null if OAuthCard sent. + public Task SignIn(OAuthOptions? options = null, CancellationToken ct = default); + + /// Sign the user out from a connection. + public Task SignOut(string? connectionName = null, CancellationToken ct = default); + + /// Check if user has a cached token (async, connection-aware). + public Task IsSignedInAsync(string? connectionName = null, CancellationToken ct = default); + + /// Check if user has a cached token (sync, backwards-compat, default connection). + public bool IsSignedIn { get; } + + /// Get token status for all configured connections. + public Task> GetConnectionStatusAsync(CancellationToken ct = default); +} +``` + +### OAuthFlow Class + +```csharp +public class OAuthFlow +{ + public string ConnectionName { get; } + + public Task GetTokenAsync(Context context, CancellationToken ct = default); + public Task SignInAsync(Context context, CancellationToken ct = default); + public Task SignInAsync(Context context, OAuthOptions? options, CancellationToken ct = default); + public Task SignOutAsync(Context context, CancellationToken ct = default); + public Task IsSignedInAsync(Context context, CancellationToken ct = default); + public Task> GetConnectionStatusAsync(Context context, CancellationToken ct = default); + + public OAuthFlow OnSignInComplete(SignInCompleteHandler handler); + public OAuthFlow OnSignInFailure(SignInFailureHandler handler); +} +``` + +### OAuthOptions + +```csharp +public class OAuthOptions +{ + public string? ConnectionName { get; set; } + public string OAuthCardText { get; set; } = "Please Sign In"; + public string SignInButtonText { get; set; } = "Sign In"; +} +``` + +### Delegates + +```csharp +public delegate Task SignInCompleteHandler( + Context context, + GetTokenResult tokenResponse, + CancellationToken cancellationToken); + +public delegate Task SignInFailureHandler( + Context context, + SignInFailureValue? failure, + CancellationToken cancellationToken); +``` + +## Internal Flow + +### SignInAsync Sequence + +``` +Developer calls context.SignIn(options) or oauth.SignInAsync(context) + │ + ├─ 1. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId) + │ ├─ Token exists → return token string + │ └─ No token ↓ + │ + ├─ 2. Build token exchange state with MsAppId (from BotApplication.AppId) + │ Call UserTokenClient.GetSignInResourceAsync(state) + │ Returns: SignInLink, TokenExchangeResource, TokenPostResource + │ + ├─ 3. Build OAuthCard attachment (serialized as JsonElement for AOT compat): + │ { + │ contentType: "application/vnd.microsoft.card.oauth", + │ content: { + │ text: options.OAuthCardText, + │ buttons: [{ type: "signin", title: options.SignInButtonText, value: signInLink }], + │ connectionName: connectionName, + │ tokenExchangeResource: { id, uri, providerId }, + │ tokenPostResource: { sasUrl } + │ } + │ } + │ + ├─ 4. Send activity with OAuthCard attachment + │ + └─ 5. Return null (sign-in pending) +``` + +**Critical**: The state must include `MsAppId` (from `BotApplication.AppId`, sourced from `BotConfig.ClientId`). Without it, the Token Service returns `tokenExchangeResource: null` and Teams cannot perform SSO or automatic verify-state after popup sign-in. + +### signin/tokenExchange Invoke Handler + +``` +Teams client sends invoke: signin/tokenExchange + │ + ├─ 1. Deserialize value → SignInTokenExchangeValue { Id, ConnectionName, Token } + │ + ├─ 2. Deduplication check (by value.Id) + │ ├─ Already processed → respond 200 (no-op) + │ └─ New ↓ + │ + ├─ 3. Resolve OAuthFlow by ConnectionName + │ + ├─ 4. Call UserTokenClient.ExchangeTokenAsync(userId, connectionName, channelId, token) + │ ├─ Success → fire OnSignInComplete, respond InvokeResponse(200) + │ └─ Failure → fire OnSignInFailure(context, null, ct): + │ ├─ NotFound/BadRequest/PreconditionFailed → respond 412 + TokenExchangeInvokeResponse body + │ └─ Other status codes (401, 403, etc.) → respond with original status code + │ + └─ 5. Record exchange Id as processed (dedup) +``` + +### signin/verifyState Invoke Handler + +``` +Teams client sends invoke: signin/verifyState + │ + ├─ 1. Deserialize value → SignInVerifyStateValue { State } + │ ├─ Null payload → respond 404 + │ └─ Parsed ↓ + │ + ├─ 2. Try each registered OAuthFlow (verifyState has no connectionName): + │ For each flow: + │ ├─ Null State → respond 404 + │ └─ Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: state) + │ ├─ Token returned → fire OnSignInComplete, respond InvokeResponse(200), stop + │ ├─ HttpException (expected) → fire OnSignInFailure, respond 412 + │ ├─ HttpException (other) → fire OnSignInFailure, respond original status code + │ └─ No token → fire OnSignInFailure, respond 412 + │ + ├─ 3. If no flow succeeded → respond 404 + │ + └─ Done +``` + +### signin/failure Invoke Handler + +``` +Teams client sends invoke: signin/failure + │ + ├─ 1. Deserialize value → SignInFailureValue { Code, Message } + │ (e.g., Code="resourcematchfailed", Message="...") + │ + ├─ 2. Log warning with user ID, conversation ID, failure code, and message. + │ Extra guidance logged for "resourcematchfailed" (check Entra app Expose an API). + │ + ├─ 3. Fire OnSignInFailure(context, failureValue, ct) on ALL registered flows + │ (no connection name in payload → notify all) + │ + └─ 4. Respond InvokeResponse(200) to acknowledge +``` + +### Deduplication + +Teams may send duplicate `signin/tokenExchange` invokes because the user can have multiple active endpoints (mobile, desktop, web) and Teams sends the exchange request from each one. The `OAuthFlow` deduplicates by tracking processed exchange IDs. + +**Default implementation**: In-process `ConcurrentDictionary` with a 5-minute TTL. This works for single-instance deployments and development. + +**Production consideration**: When the bot is deployed behind a load balancer with multiple instances (e.g., Azure App Service scaled to N nodes), duplicate `signin/tokenExchange` invokes may arrive at **different instances**. The in-process cache cannot deduplicate across instances, so the token exchange may be attempted multiple times. While the Token Service is idempotent (duplicate exchanges succeed harmlessly), the `OnSignInComplete` callback may fire more than once. + +For production multi-instance deployments, the deduplication store should be replaced with a distributed cache (e.g., Redis, Azure Cache). This is a future extensibility point -- the `OAuthFlow` should accept an `IDistributedCache` or similar abstraction to allow external storage: + +```csharp +// Future API (not yet implemented) +bot.AddOAuthFlow("GraphConnection", options => +{ + options.DeduplicationStore = new RedisDeduplicationStore(redisConnection); +}); +``` + +Until this is implemented, multi-instance deployments should be aware that `OnSignInComplete` may fire on more than one instance for the same sign-in. Handlers should be idempotent. + +## Multi-Connection Sample + +A bot that uses **two** OAuth connections: one for Microsoft Graph and one for GitHub. + +### Configuration + +Azure Bot resource has two OAuth connection settings: + +| Connection name | Provider | Scopes | +|---|---|---| +| `GraphConnection` | Azure AD v2 | `User.Read Calendars.Read` | +| `GitHubConnection` | GitHub | `repo read:user` | + +### Registration (DI pattern — recommended) + +```csharp +var builder = WebApplication.CreateSlimBuilder(args); + +// Configure OAuth flows at the DI level +builder.Services.AddTeamsBotApplication(options => +{ + options.AddOAuthFlow("GraphConnection", o => + { + o.OAuthCardText = "Sign in to your Microsoft account"; + o.SignInButtonText = "Sign In to Graph"; + }); + options.AddOAuthFlow("GitHubConnection", o => + { + o.OAuthCardText = "Sign in to your GitHub account"; + o.SignInButtonText = "Sign In to GitHub"; + }); +}); + +var app = builder.Build(); +TeamsBotApplication bot = app.UseTeamsBotApplication(); + +// Get pre-registered flows and attach callbacks +OAuthFlow graphAuth = bot.GetOAuthFlow("GraphConnection"); +OAuthFlow githubAuth = bot.GetOAuthFlow("GitHubConnection"); + +graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync($"Connected to Graph ({tokenResponse.ConnectionName})!", ct); +}); + +githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync($"Connected to GitHub ({tokenResponse.ConnectionName})!", ct); +}); + +// SignInAsync uses the OAuthCardText/SignInButtonText configured at registration +bot.OnMessage(@"(?i)^login graph$", async (context, ct) => +{ + string? token = await graphAuth.SignInAsync(context, ct); + + if (token is not null) + await context.SendActivityAsync("Already signed in to Graph.", ct); +}); + +bot.OnMessage(@"(?i)^login github$", async (context, ct) => +{ + string? token = await githubAuth.SignInAsync(context, ct); + + if (token is not null) + await context.SendActivityAsync("Already signed in to GitHub.", ct); +}); + +bot.OnMessage(@"(?i)^status$", async (context, ct) => +{ + var statuses = await context.GetConnectionStatusAsync(ct); + var lines = statuses.Select(s => + $"- **{s.ConnectionName}** ({s.ServiceProviderDisplayName}): " + + $"{(s.HasToken == true ? "connected" : "not connected")}"); + + await context.SendActivityAsync("OAuth connections:\n" + string.Join("\n", lines), ct); +}); + +bot.OnMessage(@"(?i)^logout$", async (context, ct) => +{ + await context.SignOut("GraphConnection", ct); + await context.SignOut("GitHubConnection", ct); + await context.SendActivityAsync("Signed out from all services.", ct); +}); + +app.Run(); +``` + +### How Multi-Connection Invoke Routing Works + +When multiple `OAuthFlow` instances are registered, invoke routes are registered **once** (shared). The dispatch logic differs by invoke type: + +- **`signin/tokenExchange`**: dispatches by `connectionName` from the invoke value (exact match). +- **`signin/verifyState`**: tries each registered flow sequentially (no connection name in the payload). +- **`signin/failure`**: fires `OnSignInFailure` on all registered flows (no connection name in the payload). + +## File Placement + +| File | Location | +|---|---| +| `TeamsBotApplicationOptions.cs` | `Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs` | +| `OAuthFlow.cs` | `Microsoft.Teams.Apps/Auth/OAuthFlow.cs` | +| `OAuthFlowExtensions.cs` | `Microsoft.Teams.Apps/Auth/OAuthFlowExtensions.cs` | +| `OAuthOptions.cs` | `Microsoft.Teams.Apps/Auth/OAuthOptions.cs` | +| `SignInTokenExchangeValue.cs` | `Microsoft.Teams.Apps/Auth/SignInTokenExchangeValue.cs` | +| `SignInVerifyStateValue.cs` | `Microsoft.Teams.Apps/Auth/SignInVerifyStateValue.cs` | +| `SignInFailureValue.cs` | `Microsoft.Teams.Apps/Auth/SignInFailureValue.cs` | +| `TokenExchangeInvokeResponse.cs` | `Microsoft.Teams.Apps/Auth/TokenExchangeInvokeResponse.cs` | +| `OAuthCard.cs` | `Microsoft.Teams.Apps/Schema/OAuthCard.cs` | + +## Changes to Core + +| File | Change | +|---|---| +| `BotApplication.cs` | Added `AppId` public property (from `BotApplicationOptions.AppId`) | +| `MessageHandler.cs` | Selectors now match against `TextWithoutMentions` instead of `Text` | +| `MessageActivity.cs` | Added `TextWithoutMentions` computed property (strips bot @mention) | +| `TeamsAttachment.cs` | Added `AttachmentContentType.OAuthCard` constant | + +## Edge Cases & Constraints + +| Scenario | Behavior | +|---|---| +| SSO not supported (channel scope) | SSO only works in personal and group chat. In channels, the OAuthCard shows the sign-in button directly (no token exchange). | +| User denies consent | Teams sends `signin/tokenExchange` but exchange fails. OAuthFlow responds 412 with `TokenExchangeInvokeResponse` body, Teams shows sign-in button fallback. `OnSignInFailure` fires with `failure: null`. | +| Teams SSO client failure | Teams sends `signin/failure` invoke with structured `Code`/`Message`. OAuthFlow logs the failure, fires `OnSignInFailure` on all flows with `failure: SignInFailureValue`, responds 200. | +| Duplicate `signin/tokenExchange` | Deduplicated by exchange ID. First wins, duplicates get 200 no-op. | +| Token expired | `GetTokenAsync` returns null (token store returns 404). `SignInAsync` re-initiates the flow. | +| Missing connection name with multiple flows | Throws `InvalidOperationException` listing registered connections. | +| `signin/verifyState` with multiple connections | Tries each registered flow until one succeeds (200). Returns 404 if none match. | +| `IsSignedIn` with multiple connections | Checks the first registered connection, logs `Trace.TraceWarning`. Prefer `IsSignedInAsync(connectionName)`. | +| Missing `MsAppId` in sign-in state | Token Service returns `tokenExchangeResource: null`. SSO and automatic verify-state fail. OAuthFlow includes `MsAppId` from `BotApplication.AppId` to prevent this. | +| Non-AAD providers (GitHub, etc.) | No `tokenExchangeResource` returned regardless of `MsAppId`. Sign-in completes via popup + `signin/verifyState`. | +| OAuthCard JSON serialization | `OAuthCard` is serialized to `JsonElement` before attaching, to avoid `NotSupportedException` from the source-generated `TeamsActivityJsonContext`. | diff --git a/core/docs/sso/oauthflowbot-trace-2026-04-22-sequence-diagrams.md b/core/docs/sso/oauthflowbot-trace-2026-04-22-sequence-diagrams.md new file mode 100644 index 000000000..a57c2ffa7 --- /dev/null +++ b/core/docs/sso/oauthflowbot-trace-2026-04-22-sequence-diagrams.md @@ -0,0 +1,167 @@ +# 🔐 OAuthFlowBot — Sequence Diagrams (Popup Fallback) + +Trace from 2026-04-22 03:12 UTC. Connection `teamsgraph` (Azure AD v2, no SSO configured). +Sign-in completes via **popup window** + `signin/verifyState` — no silent SSO. + +--- + +## 🔑 Login Flow (Popup Sign-In) + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant AAD as 🔵 Azure AD + participant TBS as 🟠 Token Service + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "login graph" + Teams->>Bot: 📥 POST /api/messages
type=message, text="login graph" + Note over Bot: 🛡️ JWT validated + Note over Bot: 🔀 Route: message/^login graph$ + + rect rgb(240, 248, 255) + Note over Bot,TBS: Step 1 — Silent token check (miss) + Bot->>MSAL: AcquireTokenForClient + MSAL->>AAD: POST /oauth2/v2.0/token + AAD-->>MSAL: 🔑 App token + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=teamsgraph + TBS-->>Bot: ❌ 404 No cached token + end + + rect rgb(255, 248, 240) + Note over Bot,TBS: Step 2 — Get sign-in resource + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/botsignin/GetSignInResource
state={MsAppId, ConnectionName=teamsgraph} + TBS-->>Bot: ✅ 200 signInLink + tokenPostResource
⚠️ No tokenExchangeResource (no SSO) + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 3 — Send OAuthCard (popup only) + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
🃏 OAuthCard (no tokenExchangeResource)
buttons: [Sign In → popup link] + BFC-->>Bot: ✅ 200 + end + + Bot-->>Teams: ✅ 200 + Teams->>User: Shows Sign In button + + rect rgb(255, 250, 230) + Note over User,AAD: Step 4 — User signs in via popup + User->>Teams: Clicks "Sign In" button + Teams->>AAD: Opens popup → AAD login + AAD-->>Teams: Auth code / consent + Teams->>TBS: Posts token via SasUrl + end + + rect rgb(245, 240, 255) + Note over Teams,Bot: Step 5 — Teams sends verifyState invoke + Teams->>Bot: 📥 POST /api/messages
type=invoke, name=signin/verifyState
value={ state: "745254" } + Note over Bot: 🛡️ JWT validated + Note over Bot: 🔀 Route: invoke/signin/verifyState + end + + rect rgb(255, 245, 245) + Note over Bot,TBS: Step 6 — Verify state and get token + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=teamsgraph&code=745254 + TBS-->>Bot: ✅ 200 User token returned + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 7 — 🎉 OnSignInComplete + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
"Connected to Microsoft Graph (teamsgraph)!" + BFC-->>Bot: ✅ 201 + end + + Bot-->>Teams: ✅ 200 invoke response + Teams->>User: "Connected to Microsoft Graph!" +``` + +--- + +## 👤 "my ad user" Flow (token cached) + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant TBS as 🟠 Token Service + participant Graph as 📊 Graph + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "my ad user" + Teams->>Bot: 📥 POST /api/messages
type=message, text="my ad user" + Note over Bot: 🔀 Route: message/^my ad user + + rect rgb(240, 248, 255) + Note over Bot,TBS: Step 1 — Silent token check (hit) + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=teamsgraph + TBS-->>Bot: ✅ 200 Cached user token + end + + rect rgb(245, 240, 255) + Note over Bot,Graph: Step 2 — Call Graph API + Bot->>Graph: 📤 GET /v1.0/me
🔑 Bearer {user_token} + Graph-->>Bot: ✅ 200 {displayName:"Rido", mail:"rido@teamssdk..."} + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 3 — Send profile to user + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
📄 Graph /me JSON + BFC-->>Bot: ✅ 201 + end + + Bot-->>Teams: ✅ 200 + Teams->>User: Shows AD user JSON +``` + +--- + +## 🚪 Logout Flow + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant TBS as 🟠 Token Service + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "logout graph" + Teams->>Bot: 📥 POST /api/messages
type=message, text="logout graph" + Note over Bot: 🔀 Route: message/^logout graph$ + + rect rgb(255, 240, 240) + Note over Bot,TBS: Step 1 — Revoke user token + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 DELETE /api/usertoken/SignOut
connectionName=teamsgraph + TBS-->>Bot: ✅ 200 Token revoked + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 2 — Send confirmation + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
"Signed out from Graph." + BFC-->>Bot: ✅ 201 + end + + Bot-->>Teams: ✅ 200 + Teams->>User: "Signed out from Graph." +``` diff --git a/core/docs/sso/oauthflowbot-trace-2026-04-22-summary.md b/core/docs/sso/oauthflowbot-trace-2026-04-22-summary.md new file mode 100644 index 000000000..782cd35cf --- /dev/null +++ b/core/docs/sso/oauthflowbot-trace-2026-04-22-summary.md @@ -0,0 +1,355 @@ +# 🔐 OAuthFlowBot Trace Summary (Popup Fallback) + +**Date**: 2026-04-22 03:12:00 UTC +**Bot**: my-bot-sso (AppID: `e3cb1c84-14e3-419c-b39c-1c06097b55fd`) +**User**: Rido (aadObjectId: `03500558-e554-416c-90c3-a061cdcd012b`) +**Connection**: `teamsgraph` (Azure AD v2, no SSO — popup fallback) +**Platform**: 🌐 Web (Teams) +**SDK Version**: `0.0.1-alpha-0107-g1c503584a7` +**Result**: ✅ SUCCESS (login graph + my ad user + logout graph) + +> **Key difference from SsoBot**: This connection does not have `tokenExchangeResource` (SSO not configured). +> Login completes via **popup sign-in** + `signin/verifyState` instead of silent `signin/tokenExchange`. + +### 🆔 Identity Reference + +| Identity | MRI / Value | +|----------|-------------| +| User MRI | `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` | +| User AAD ObjectId | `03500558-e554-416c-90c3-a061cdcd012b` | +| Bot MRI | `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` | +| Bot AppId | `e3cb1c84-14e3-419c-b39c-1c06097b55fd` | +| Tenant Id | `3f3d1cea-7a18-41af-872b-cfbbd5140984` | +| Conversation Id | `a:1xH4HncZ6lyZnMVYp9rTKoRyS44qDCikYZ1u-Q0VNmZqyceL6nKfe5ZKG9CqOi2WuXNDJyLBAaDgVChKMxKFPlAZ5bsy0_8RhvPYYi5ZJJKCiia_SEd_e8WJVlSHOIM3Z` | +| Service URL | `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` | + +--- + +## 🔑 Login Flow (Popup Fallback — no SSO) + +### Step 1 — User sends "login graph" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` +- **Activity**: + - `type`: `message` + - `id`: `1776827520098` + - `channelId`: `msteams` + - `text`: `"login graph"` + - `textFormat`: `plain` + - `timestamp`: `2026-04-22T03:12:00.1176725Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.name`: `Rido` + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `recipient.name`: `my-bot-sso` + - `conversation.id`: `a:1xH4HncZ6ly...OIM3Z` + - `conversation.conversationType`: `personal` + - `conversation.tenantId`: `3f3d1cea-7a18-41af-872b-cfbbd5140984` + - `serviceUrl`: `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` + - `entities[0]`: `{ locale: "en-US", country: "US", platform: "Web", timezone: "America/Los_Angeles", type: "clientInfo" }` + - `MSCV`: `4+YxTIufBEq78SeAVHsSdQ.1.1.1.485196365.1.1` +- 🛡️ JWT validated (AzureAd scheme) +- 🔀 Route: `message/(?i)^login graph$` + +### Step 2 — Silent token check (no cached token) + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `teamsgraph` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL AcquireTokenForClient (source: IdentityProvider) — first token from AAD +- ❌ **Response**: `404` — no cached user token + +### Step 3 — Get sign-in resource + +📤 **OUTGOING** `GET https://token.botframework.com/api/botsignin/GetSignInResource` +- **Query Parameters**: + - `state`: base64-encoded JSON: + ```json + { + "ConnectionName": "teamsgraph", + "Conversation": { + "ActivityId": "1776827520098", + "Bot": { "Id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd" }, + "ChannelId": "msteams", + "Conversation": { "Id": "a:1xH4HncZ6ly...OIM3Z" }, + "ServiceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "User": { "Id": "29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ" } + }, + "MsAppId": "e3cb1c84-14e3-419c-b39c-1c06097b55fd" + } + ``` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache +- ✅ **Response**: `200` — returns signInLink + tokenPostResource, **⚠️ NO tokenExchangeResource** (SSO not configured) + +### Step 4 — Send OAuthCard to user (popup only, no SSO) + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776827520098` +- **Auth**: 🔑 MSAL from cache +- **Request Body**: + ```json + { + "from": { + "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", + "name": "my-bot-sso" + }, + "recipient": { + "id": "29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ", + "name": "Rido", + "aadObjectId": "03500558-e554-416c-90c3-a061cdcd012b" + }, + "conversation": { + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984", + "conversationType": "personal", + "id": "a:1xH4HncZ6ly...OIM3Z" + }, + "attachments": [{ + "contentType": "application/vnd.microsoft.card.oauth", + "content": { + "text": "Please Sign In", + "connectionName": "teamsgraph", + "buttons": [{ + "type": "signin", + "title": "Sign In", + "value": "https://token.botframework.com/api/oauth/signin?signin=02706e367e884e8ea4e86472cbd71932" + }], + "tokenPostResource": { + "SasUrl": "https://token.botframework.com/api/sas/postToken?expiry=1776827583&id=key2&state=02706e367e884e8ea4e86472cbd71932&hmac=..." + } + } + }], + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776827520098" + } + ``` + > **Note**: `tokenExchangeResource` is **omitted** (not sent as null). This is the fix applied in this run — previously it was serialized as `"tokenExchangeResource": null` which caused Teams to reject with `BadRequest`. +- ✅ **Response**: `200` + +🏁 **HTTP Response to Teams**: `200` + +### Step 5 — User completes popup sign-in, Teams sends signin/verifyState + +📥 **INCOMING** `POST http://localhost:3978/api/messages` +- **Activity**: + - `type`: `invoke` + - `name`: `signin/verifyState` + - `id`: `f:7d1e8ec2-5897-396e-aa7b-f579ad2fac9f` + - `channelId`: `msteams` + - `timestamp`: `2026-04-22T03:12:09.445Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.name`: `Rido` + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `recipient.name`: `my-bot-sso` + - `conversation.id`: `a:1xH4HncZ6ly...OIM3Z` + - `conversation.conversationType`: `personal` + - `conversation.tenantId`: `3f3d1cea-7a18-41af-872b-cfbbd5140984` + - `serviceUrl`: `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` + - `replyToId`: `1776827524158` + - `channelData.source.name`: `message` + - `channelData.legacy.replyToId`: `1:1m2Cdy7qBU0p3417d81g04kt7MXJrjQC-X21CRiZVWzk` + - `value`: `{ "state": "745254" }` *(verification code from popup)* + - `MSCV`: `FvSbzMYrUE+OReNyK+4lyg.1.3` +- 🛡️ JWT validated (AzureAd scheme) +- 🔀 Route: `invoke/signin/verifyState` + +### Step 6 — Verify state and get token + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `teamsgraph` + - `channelId`: `msteams` + - `code`: `745254` *(verification code from verifyState)* +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache +- ✅ **Response**: `200` — user token returned + +### Step 7 — 🎉 OnSignInComplete fires, bot sends confirmation + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/f:7d1e8ec2-5897-396e-aa7b-f579ad2fac9f` +- **Auth**: 🔑 MSAL from cache +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "f:7d1e8ec2-5897-396e-aa7b-f579ad2fac9f", + "text": "Connected to Microsoft Graph (teamsgraph)!", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` + +🏁 **Invoke Response**: `200` (body: null) + +--- + +## 👤 "my ad user" Flow (token cached) + +### Step 8 — User sends "my ad user" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` +- **Activity**: + - `type`: `message` + - `id`: `1776827541160` + - `text`: `"my ad user"` + - `textFormat`: `plain` + - `timestamp`: `2026-04-22T03:12:21.179708Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `attachments[0]`: `{ contentType: "text/html", content: "
my ad user
" }` + - `MSCV`: `eTD3PgxXhEiK0mFFc7QunQ.1.1.1.485974193.1.1` +- 🔀 Route: `message/(?i)^my ad user` + +### Step 9 — Silent token check (token exists) + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `teamsgraph` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache +- ✅ **Response**: `200` — cached user token returned + +### Step 10 — Call Graph API with token + +📤 **OUTGOING** `GET https://graph.microsoft.com/v1.0/me` +- **Auth**: `Authorization: Bearer {user_token}` +- ✅ **Response**: `200` — `{ displayName: "Rido", mail: "rido@teamssdk.onmicrosoft.com", id: "03500558-e554-416c-90c3-a061cdcd012b" }` + +### Step 11 — Send profile result + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776827541160` +- **Auth**: 🔑 MSAL from cache +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776827541160", + "text": "Your Azure AD user :\n```json\n{\"displayName\":\"Rido\",\"givenName\":\"Rido\",\"jobTitle\":\"Not an architect\",\"mail\":\"rido@teamssdk.onmicrosoft.com\",...}\n```", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` + +--- + +## 🚪 Logout Flow + +### Step 12 — User sends "logout graph" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` +- **Activity**: + - `type`: `message` + - `id`: `1776827548949` + - `text`: `"logout graph"` + - `textFormat`: `plain` + - `timestamp`: `2026-04-22T03:12:28.9762671Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `MSCV`: `ymRk2x/XZ0CFG0QGeNHrOg.1.1.1.486335532.1.1` +- 🔀 Route: `message/(?i)^logout graph$` + +### Step 13 — Sign out user + +📤 **OUTGOING** `DELETE https://token.botframework.com/api/usertoken/SignOut` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `teamsgraph` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache +- ✅ **Response**: `200` — token revoked + +### Step 14 — Send confirmation + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776827548949` +- **Auth**: 🔑 MSAL from cache +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776827548949", + "text": "Signed out from Graph.", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` + +--- + +## 📊 Request Summary Table + +| # | Direction | Method | Endpoint | Status | Purpose | +|---|-----------|--------|----------|--------|---------| +| 1 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | 💬 "login graph" message | +| 2 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ❌ 404 | 🔍 Silent token check (miss) | +| 3 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/botsignin/GetSignInResource` | ✅ 200 | 🔗 Get sign-in resource (no tokenExchangeResource) | +| 4 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 200 | 🃏 Send OAuthCard (popup only, no SSO) | +| 5 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | 🔄 signin/verifyState invoke (code=745254) | +| 6 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ✅ 200 | 🔐 Verify state + get token (code=745254) | +| 7 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | 🎉 "Connected to Microsoft Graph!" | +| 8 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | 💬 "my ad user" message | +| 9 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ✅ 200 | 🔍 Silent token check (hit) | +| 10 | 📤 ⬆️ OUT | GET | `graph.microsoft.com/v1.0/me` | ✅ 200 | 👤 Graph API call | +| 11 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | 📄 Profile response | +| 12 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | 💬 "logout graph" message | +| 13 | 📤 ⬆️ OUT | DELETE | `token.botframework.com/api/usertoken/SignOut` | ✅ 200 | 🚪 Revoke token | +| 14 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | 💬 "Signed out from Graph." | + +## 🆔 User MRI Usage Across Requests + +| Request | Where User MRI appears | Format | +|---------|----------------------|--------| +| Step 1 (incoming message) | `activity.from.id` | `29:1cgsv1oFLAoTflZ-...` | +| Step 2 (GetToken) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 3 (GetSignInResource) | `state.Conversation.User.Id` (base64 JSON) | `29:1cgsv1oFLAoTflZ-...` | +| Step 4 (Send OAuthCard) | `recipient.id` (reply to user) | `29:1cgsv1oFLAoTflZ-...` | +| Step 5 (verifyState invoke) | `activity.from.id` | `29:1cgsv1oFLAoTflZ-...` | +| Step 6 (GetToken + code) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 9 (GetToken cached) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 13 (SignOut) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | + +> **Note**: The User MRI (`29:...`) is the Teams-specific identifier. It is used as `userid` in all Token Bot Service calls (GetToken, SignOut) and appears in `from.id` on incoming activities and `recipient.id` on outgoing replies. The AAD ObjectId (`03500558-...`) appears separately in `from.aadObjectId` and in the outgoing `recipient.aadObjectId`. + +--- + +## 🔑 vs SsoBot: Key Differences + +| Aspect | SsoBot (`sso` connection) | OAuthFlowBot (`teamsgraph` connection) | +|--------|--------------------------|----------------------------------------| +| SSO support | ✅ `tokenExchangeResource` present | ❌ `tokenExchangeResource` omitted | +| Sign-in invoke | `signin/tokenExchange` (silent) | `signin/verifyState` (popup + code) | +| Token acquisition | `POST /api/usertoken/exchange` with SSO JWT | `GET /api/usertoken/GetToken` with `code` param | +| User interaction | None (fully silent) | Popup window + consent | +| OAuthFlow API | Context API (`context.SignIn()`) | Instance API (`graphAuth.SignInAsync(context)`) | +| verifyState value | N/A | `{ "state": "745254" }` | +| tokenExchange value | `{ id, connectionName, token }` | N/A | + +## 🐛 Bug Fixed During This Run + +**Issue**: `OAuthCard` serialized `"tokenExchangeResource": null` explicitly in JSON. Teams rejected this with `BadRequest: {"error":{"code":"ServiceError","message":"Unknown"}}`. + +**Fix**: Added `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` to `TokenExchangeResource` and `TokenPostResource` properties in `OAuthCard.cs`. When null, these properties are now omitted from the JSON instead of being sent as explicit nulls. + +**File**: `src/Microsoft.Teams.Apps/Schema/OAuthCard.cs` diff --git a/core/docs/sso/security-audit-oauthflow-2026-04-22.md b/core/docs/sso/security-audit-oauthflow-2026-04-22.md new file mode 100644 index 000000000..ef7f6685b --- /dev/null +++ b/core/docs/sso/security-audit-oauthflow-2026-04-22.md @@ -0,0 +1,225 @@ +# Security Audit: OAuthFlow Token Retrieval Attack Surface + +**Date:** 2026-04-22 +**Scope:** OAuthFlow design, implementation, live traffic trace, Azure Bot Service + Entra ID configuration +**Bot App ID:** `e3cb1c84-14e3-419c-b39c-1c06097b55fd` ("my-bot-sso") +**Tenant:** `3f3d1cea-7a18-41af-872b-cfbbd5140984` + +--- + +## Executive Summary + +The Bot Framework Token Service (`token.botframework.com`) acts as a **centralized token vault** for all user tokens acquired through OAuth connections. **Any caller that can authenticate as the bot** (i.e., possesses the bot's `AppId` + client secret) can retrieve any user's cached token by calling a single unauthenticated-beyond-app-identity API. The only inputs needed are: + +- The bot's credentials (AppId + secret) +- A user's Teams MRI (semi-public, visible to anyone in the same org/conversation) +- The connection name (a short string like `"teamsgraph"`) + +This is **by design** in the Bot Framework Token Service protocol. The mitigation is entirely dependent on protecting the bot's client secret. + +--- + +## Detailed Attack Reconstruction + +### What the trace shows + +From the live traffic trace (`oauthflowbot-trace-2026-04-22-raw.log`), the token retrieval call is: + +``` +GET https://token.botframework.com/api/usertoken/GetToken + ?userid=29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ + &connectionName=teamsgraph + &channelId=msteams +Authorization: Bearer +``` + +The authorization token is an **app-only** token (no user context), acquired by the bot using its own credentials: +``` +aud: https://api.botframework.com +appid: e3cb1c84-14e3-419c-b39c-1c06097b55fd +idtyp: app +``` + +### The attack (step by step) + +An attacker with access to the bot's client secret can reproduce this outside the bot: + +1. **Acquire app-only token:** + ```bash + curl -X POST https://login.microsoftonline.com/3f3d1cea-7a18-41af-872b-cfbbd5140984/oauth2/v2.0/token \ + -d "client_id=e3cb1c84-14e3-419c-b39c-1c06097b55fd" \ + -d "client_secret=" \ + -d "scope=https://api.botframework.com/.default" \ + -d "grant_type=client_credentials" + ``` + +2. **Retrieve any user's token:** + ```bash + curl "https://token.botframework.com/api/usertoken/GetToken?\ + userid=29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ\ + &connectionName=teamsgraph\ + &channelId=msteams" \ + -H "Authorization: Bearer " + ``` + +3. **Use the returned delegated token** to call Microsoft Graph, GitHub, etc. as the victim user. + +### What tokens are at risk + +| Connection | Provider App | Scopes | Impact | +|---|---|---|---| +| `teamsgraph` | `9f43e2fb-2cbd-4303-aaf4-c6d209dc2666` ("RidoGraphExperiment") | `ChannelMessage.Read.All TeamMember.Read.All` | **Read ALL channel messages and team members as the user** | +| `sso` | `e3cb1c84-14e3-419c-b39c-1c06097b55fd` (bot itself) | `User.Read Calendars.Read` | Read user profile and calendar | +| `gh` | `Ov23ligyZwD5j1u41P81` (GitHub OAuth App) | `repo pr` | **Full access to user's GitHub repositories** | +| `sso-bad` | Unknown | Unknown | (likely a test connection) | + +### How easy is it to get the inputs? + +| Input | Difficulty | How | +|---|---|---| +| Bot AppId | **Trivial** | Visible in every activity (`recipient.id`), in the OAuthCard, in the base64 state, in bot manifests | +| Client secret | **Medium** | Stored in: app settings, Key Vault, CI/CD pipelines, developer machines, `.env` files. Hint: `a-t`, expires 2028-04-20 | +| User MRI | **Low** | Visible to any user in the same conversation. Format: `29:`. Enumerable via Graph API with `TeamMember.Read.All` | +| Connection name | **Low** | Visible in OAuthCard payload (`connectionName: "teamsgraph"`), guessable, or enumerable if you have the bot's Azure subscription access | + +--- + +## Configuration Findings + +### Finding 1: CRITICAL — Overprivileged OAuth Connection Scopes + +The `teamsgraph` connection requests `ChannelMessage.Read.All` and `TeamMember.Read.All`. These are high-privilege delegated permissions that grant access far beyond what the sample bot uses (it only calls `/me`). + +If a user's token is stolen via the attack above, the attacker gets these broad permissions for free. + +**Recommendation:** Apply least-privilege. The sample only needs `User.Read`. Remove `ChannelMessage.Read.All` and `TeamMember.Read.All` from the connection scopes. + +### Finding 2: HIGH — `teamsgraph` Uses a Separate App Registration + +The `teamsgraph` connection's `clientId` is `9f43e2fb-2cbd-4303-aaf4-c6d209dc2666` ("RidoGraphExperiment") — a **different** app registration than the bot itself. This means: + +- The Token Service performs OBO (on-behalf-of) using this separate app's credentials +- This separate app has its own client secrets (hints: `2PX` expiring 2026-10-17, `Fta` expiring 2027-04-17) +- Two sets of credentials must be protected, doubling the attack surface +- The `RidoGraphExperiment` app's credentials are stored in the Token Service, not in the bot's code, but if the bot credentials are compromised, the stored tokens (already exchanged) are directly accessible + +**Recommendation:** Use the bot's own app ID for the OAuth connection where possible (as the `sso` connection already does). This reduces the number of credential sets to protect. + +### Finding 3: HIGH — `signInAudience` vs `msaAppType` Mismatch + +| Setting | Value | +|---|---| +| Entra App `signInAudience` | `AzureADMultipleOrgs` (any Entra tenant) | +| Bot Service `msaAppType` | `SingleTenant` | + +The Entra app accepts tokens from **any** Azure AD tenant, but the Bot Service is configured as single-tenant. This mismatch means: + +- An attacker from a different tenant could acquire an app-only token against `https://api.botframework.com/.default` using a service principal in their own tenant (if the app is registered as multi-org) +- The Bot Framework Token Service may or may not enforce tenant isolation on the `GetToken` API + +**Recommendation:** Align the Entra app `signInAudience` to `AzureADMyOrg` (single tenant) to match the Bot Service configuration. This restricts token acquisition to the bot's home tenant. + +### Finding 4: MEDIUM — `appRoleAssignmentRequired: false` + +The bot's service principal does not require role assignment. Combined with `AzureADMultipleOrgs`, any user in any tenant can authenticate. While this is typical for bots (they need to accept tokens from the Bot Framework), it should be reviewed. + +### Finding 5: MEDIUM — Dev Tunnel Endpoint in Production Bot Registration + +The messaging endpoint is: +``` +https://klljrqz0-3978.usw2.devtunnels.ms/api/messages +``` + +This is a dev tunnel URL. If this bot registration is also used for testing with real user tokens, those tokens are cached in the Token Service and retrievable even after the dev tunnel is shut down. The tokens persist until they expire or the user signs out. + +### Finding 6: MEDIUM — GitHub Connection Has `repo` Scope + +The `gh` connection grants `repo` scope — full read/write access to all repositories. A stolen GitHub token would allow an attacker to read private code, push malicious commits, or exfiltrate proprietary source code. + +**Recommendation:** Use fine-grained GitHub permissions or the minimum scope needed. + +--- + +## What the SDK Can and Cannot Do + +### Cannot fix (Bot Framework Token Service design) + +The core issue — that `GetToken` only requires bot identity + userId — is a **Token Service protocol property**. The Token Service treats the bot as a trusted party for all its users. This is analogous to how a web app's backend can use its OAuth client credentials to access stored refresh tokens. + +The SDK cannot add additional authorization to the Token Service API. + +### Can mitigate + +| Mitigation | Where | Status | +|---|---|---| +| **Document the threat model** | Design doc, SDK docs | Not done | +| **Warn about credential protection** | Sample README, getting-started guide | Not done | +| **Log token retrieval attempts** | OAuthFlow.cs `GetTokenAsync` | Partially done (debug-level) | +| **Support Managed Identity** | BotConfig.cs | Supported (eliminates client secret) | +| **Support Federated Identity** | BotConfig.cs | Supported (eliminates client secret) | +| **Reduce default log verbosity** | BotAuthenticationHandler.cs | Not done (full claims at Trace) | + +--- + +## Recommendations (Priority Order) + +### 1. Eliminate the client secret (P0) + +The **single most effective mitigation** is to remove the client secret entirely: + +- **Managed Identity**: If the bot runs on Azure (App Service, Container Apps), use system-assigned managed identity. No secret to steal. +- **Federated Identity Credentials**: For non-Azure hosts or CI/CD, use workload identity federation. No secret stored. + +The bot's `BotConfig` already supports both (`Credential.ManagedIdentity`, `Credential.FederatedIdentity`). The sample should demonstrate this. + +### 2. Fix the `signInAudience` mismatch (P0) + +```bash +az ad app update --id e3cb1c84-14e3-419c-b39c-1c06097b55fd \ + --sign-in-audience AzureADMyOrg +``` + +This ensures only the home tenant (`3f3d1cea-...`) can acquire tokens for this app. + +### 3. Apply least-privilege scopes to OAuth connections (P1) + +For `teamsgraph`: change scopes from `ChannelMessage.Read.All TeamMember.Read.All` to `User.Read` (what the sample actually uses). + +For `gh`: change from `repo pr` to `read:user` if only profile info is needed. + +### 4. Consolidate to a single app registration (P1) + +Use the bot's own app ID (`e3cb1c84-...`) for the `teamsgraph` OAuth connection instead of the separate `RidoGraphExperiment` app. This halves the credential surface. + +### 5. Document the Token Service threat model (P1) + +Add to the OAuthFlow design doc: + +> **Security Note:** The Bot Framework Token Service stores user tokens on behalf of the bot. Any entity that can authenticate as the bot (via AppId + credential) can retrieve any user's cached token by calling the Token Service API with the user's ID and connection name. Protect the bot's credentials with the same rigor as a database connection string. Prefer Managed Identity or Federated Identity Credentials over client secrets. + +### 6. Rotate the existing client secret (P1) + +The current secret (hint: `a-t`, created 2026-04-20, expires 2028-04-20) has a **2-year lifetime** — far too long. Rotate immediately and set a shorter expiry (90 days max) as a bridge while migrating to Managed Identity. + +### 7. Clean up the dev tunnel endpoint (P2) + +If this bot registration was used with real users during development, their tokens may still be cached. Either: +- Sign out all users via the Token Service API +- Delete and recreate the bot registration for production use + +--- + +## Appendix: Token Service API Surface (Attack-Relevant) + +All endpoints authenticated with bot's app-only token for `https://api.botframework.com/.default`: + +| Endpoint | Method | What it does | +|---|---|---| +| `/api/usertoken/GetToken?userid=X&connectionName=Y&channelId=Z` | GET | **Returns the user's cached access token** | +| `/api/usertoken/GetToken?userid=X&connectionName=Y&channelId=Z&code=C` | GET | Exchanges a verify-state code for a token | +| `/api/usertoken/SignOut?userid=X&connectionName=Y&channelId=Z` | DELETE | Revokes a user's cached token | +| `/api/usertoken/GetTokenStatus?userid=X&channelId=Z` | GET | Lists all connections and whether tokens exist | +| `/api/usertoken/exchange?userid=X&connectionName=Y&channelId=Z` | POST | Exchanges an SSO token for an access token | +| `/api/botsignin/GetSignInResource?state=X` | GET | Returns sign-in URL + TokenExchangeResource | + +Every one of these is callable by anyone with the bot's credentials. The `GetTokenStatus` endpoint even lets an attacker enumerate which connections a user has tokens for without knowing the connection names. diff --git a/core/docs/sso/sso-trace-2026-04-22-sequence-diagrams.md b/core/docs/sso/sso-trace-2026-04-22-sequence-diagrams.md new file mode 100644 index 000000000..6759125d3 --- /dev/null +++ b/core/docs/sso/sso-trace-2026-04-22-sequence-diagrams.md @@ -0,0 +1,161 @@ +# 🔐 SsoBot — Sequence Diagrams (Silent SSO) + +Trace from 2026-04-22 02:45 UTC. Connection `sso` (Azure AD v2 with SSO). +Sign-in completes via silent `signin/tokenExchange` — no popup needed. + +--- + +## 🔑 Login Flow + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant AAD as 🔵 Azure AD + participant TBS as 🟠 Token Service + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "login" + Teams->>Bot: 📥 POST /api/messages
type=message, text="login" + Note over Bot: 🛡️ JWT validated + Note over Bot: 🔀 Route: message/^login$ + + rect rgb(240, 248, 255) + Note over Bot,TBS: Step 1 — Silent token check (miss) + Bot->>MSAL: AcquireTokenForClient + MSAL->>AAD: GET /common/discovery/instance + AAD-->>MSAL: Instance metadata + MSAL->>AAD: POST /oauth2/v2.0/token + AAD-->>MSAL: 🔑 App token (⏱️ 535ms) + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=sso + TBS-->>Bot: ❌ 404 No cached token + end + + rect rgb(255, 248, 240) + Note over Bot,TBS: Step 2 — Get sign-in resource + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/botsignin/GetSignInResource
state={MsAppId, ConnectionName, Conversation} + TBS-->>Bot: ✅ 200 signInLink + tokenExchangeResource + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 3 — Send OAuthCard (with SSO) + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
🃏 OAuthCard
tokenExchangeResource.Uri=api://botid-... + BFC-->>Bot: ✅ 202 Accepted (⏱️ 631ms) + end + + Bot-->>Teams: ✅ 200 (⏱️ 3034ms total) + Teams->>User: Shows OAuthCard / triggers silent SSO + + rect rgb(245, 240, 255) + Note over Teams,Bot: Step 4 — Teams sends SSO token + Teams->>Bot: 📥 POST /api/messages
type=invoke, name=signin/tokenExchange
token=SSO JWT (scp=access_as_user) + Note over Bot: 🛡️ JWT validated + Note over Bot: 🔀 Route: invoke/signin/tokenExchange + end + + rect rgb(255, 245, 245) + Note over Bot,TBS: Step 5 — Exchange SSO token + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 POST /api/usertoken/exchange
connectionName=sso, token=SSO JWT + TBS-->>Bot: ✅ 200 User token (⏱️ 903ms) + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 6 — 🎉 OnSignInComplete + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
"You're now signed in!" + BFC-->>Bot: ✅ 201 (⏱️ 366ms) + end + + Bot-->>Teams: ✅ 200 invoke response (⏱️ 1308ms total) + Teams->>User: "You're now signed in!" +``` + +--- + +## 👤 Profile Flow (token cached) + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant TBS as 🟠 Token Service + participant Graph as 📊 Graph + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "profile" + Teams->>Bot: 📥 POST /api/messages
type=message, text="profile" + Note over Bot: 🔀 Route: message/^profile$ + + rect rgb(240, 248, 255) + Note over Bot,TBS: Step 1 — Silent token check (hit) + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 GET /api/usertoken/GetToken
connectionName=sso + TBS-->>Bot: ✅ 200 Cached user token (⏱️ 214ms) + end + + rect rgb(245, 240, 255) + Note over Bot,Graph: Step 2 — Call Graph API + Bot->>Graph: 📤 GET /v1.0/me
🔑 Bearer {user_token} + Graph-->>Bot: ✅ 200 {displayName:"Rido", mail:"rido@teamssdk..."} + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 3 — Send profile to user + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
📄 Graph /me JSON + BFC-->>Bot: ✅ 201 (⏱️ 283ms) + end + + Bot-->>Teams: ✅ 200 (⏱️ 664ms total) + Teams->>User: Shows profile JSON +``` + +--- + +## 🚪 Logout Flow + +```mermaid +sequenceDiagram + actor User as 👤 Rido + participant Teams as 🟣 Teams + participant Bot as 🤖 Bot + participant MSAL as 🔑 MSAL + participant TBS as 🟠 Token Service + participant BFC as 🔷 Bot Framework + + User->>Teams: Types "logout" + Teams->>Bot: 📥 POST /api/messages
type=message, text="logout" + Note over Bot: 🔀 Route: message/^logout$ + + rect rgb(255, 240, 240) + Note over Bot,TBS: Step 1 — Revoke user token + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>TBS: 📤 DELETE /api/usertoken/SignOut
connectionName=sso + TBS-->>Bot: ✅ 200 Token revoked (⏱️ 313ms) + end + + rect rgb(240, 255, 240) + Note over Bot,BFC: Step 2 — Send confirmation + Bot->>MSAL: AcquireTokenForClient + MSAL-->>Bot: 💾 Cached + Bot->>BFC: 📤 POST /v3/.../activities
"Signed out." + BFC-->>Bot: ✅ 201 (⏱️ 339ms) + end + + Bot-->>Teams: ✅ 200 (⏱️ 662ms total) + Teams->>User: "Signed out." +``` diff --git a/core/docs/sso/sso-trace-2026-04-22-summary.md b/core/docs/sso/sso-trace-2026-04-22-summary.md new file mode 100644 index 000000000..3323025aa --- /dev/null +++ b/core/docs/sso/sso-trace-2026-04-22-summary.md @@ -0,0 +1,347 @@ +# 🔐 SsoBot Trace Summary (Silent SSO) + +**Date**: 2026-04-22 02:45:26 UTC +**Bot**: my-bot-sso (AppID: `e3cb1c84-14e3-419c-b39c-1c06097b55fd`) +**User**: Rido (aadObjectId: `03500558-e554-416c-90c3-a061cdcd012b`) +**Connection**: `sso` +**Platform**: 🌐 Web (Teams) +**SDK Version**: `0.0.1-alpha-0107-g1c503584a7` +**Result**: ✅ SUCCESS (login + profile + logout) + +### 🆔 Identity Reference + +| Identity | MRI / Value | +|----------|-------------| +| User MRI | `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` | +| User AAD ObjectId | `03500558-e554-416c-90c3-a061cdcd012b` | +| Bot MRI | `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` | +| Bot AppId | `e3cb1c84-14e3-419c-b39c-1c06097b55fd` | +| Tenant Id | `3f3d1cea-7a18-41af-872b-cfbbd5140984` | +| Conversation Id | `a:1xH4HncZ6lyZnMVYp9rTKoRyS44qDCikYZ1u-Q0VNmZqyceL6nKfe5ZKG9CqOi2WuXNDJyLBAaDgVChKMxKFPlAZ5bsy0_8RhvPYYi5ZJJKCiia_SEd_e8WJVlSHOIM3Z` | +| Service URL | `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` | + +--- + +## 🔑 Login Flow + +### Step 1 — User sends "login" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` (1017 bytes) +- **Request Headers**: `Content-Type: application/json;+charset=utf-8` +- **Activity**: + - `type`: `message` + - `id`: `1776825925953` + - `channelId`: `msteams` + - `text`: `"login"` + - `textFormat`: `plain` + - `timestamp`: `2026-04-22T02:45:26.0070993Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.name`: `Rido` + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `recipient.name`: `my-bot-sso` + - `conversation.id`: `a:1xH4HncZ6ly...OIM3Z` + - `conversation.conversationType`: `personal` + - `conversation.tenantId`: `3f3d1cea-7a18-41af-872b-cfbbd5140984` + - `serviceUrl`: `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` + - `entities[0]`: `{ locale: "en-US", country: "US", platform: "Web", timezone: "America/Los_Angeles", type: "clientInfo" }` + - `MSCV`: `V3M44DfUokajTFBIXtrInA.1.1.1.422967360.1.1` +- 🛡️ JWT validated (AzureAd scheme) +- 🔀 Route: `message/(?i)^login$` + +### Step 2 — Silent token check (no cached token) + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `sso` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL AcquireTokenForClient (source: IdentityProvider, ⏱️ 535ms) — first token from AAD +- ❌ **Response**: `404` (⏱️ 568ms) — no cached user token + +### Step 3 — Get sign-in resource + +📤 **OUTGOING** `GET https://token.botframework.com/api/botsignin/GetSignInResource` +- **Query Parameters**: + - `state`: base64-encoded JSON: + ```json + { + "ConnectionName": "sso", + "Conversation": { + "ActivityId": "1776825925953", + "Bot": { "Id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd" }, + "ChannelId": "msteams", + "Conversation": { "Id": "a:1xH4HncZ6ly...OIM3Z" }, + "ServiceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "User": { "Id": "29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ" } + }, + "MsAppId": "e3cb1c84-14e3-419c-b39c-1c06097b55fd" + } + ``` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- ✅ **Response**: `200` (⏱️ 286ms) — returns signInLink, tokenExchangeResource, tokenPostResource + +### Step 4 — Send OAuthCard to user + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776825925953?isTargetedActivity=true` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- **Request Body**: + ```json + { + "from": { + "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", + "name": "my-bot-sso" + }, + "recipient": { + "id": "29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ", + "name": "Rido", + "isTargeted": true, + "aadObjectId": "03500558-e554-416c-90c3-a061cdcd012b" + }, + "conversation": { + "tenantId": "3f3d1cea-7a18-41af-872b-cfbbd5140984", + "conversationType": "personal", + "id": "a:1xH4HncZ6ly...OIM3Z" + }, + "attachments": [{ + "contentType": "application/vnd.microsoft.card.oauth", + "content": { + "text": "Please Sign In", + "connectionName": "sso", + "buttons": [{ + "type": "signin", + "title": "Sign In", + "value": "https://token.botframework.com/api/oauth/signin?signin=893cf4ca0d6943fca7754c614f20451c" + }], + "tokenExchangeResource": { + "Id": "fc67c7b5-d0d4-494c-a0e9-3a7ddec999f0", + "ProviderId": "30dd229c-58e3-4a48-bdfd-91ec48eb906c", + "Uri": "api://botid-e3cb1c84-14e3-419c-b39c-1c06097b55fd" + }, + "tokenPostResource": { + "SasUrl": "https://token.botframework.com/api/sas/postToken?expiry=1776825989&id=key1&state=893cf4ca0d6943fca7754c614f20451c&hmac=..." + } + } + }], + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776825925953" + } + ``` +- ✅ **Response**: `202 Accepted` (⏱️ 631ms) + +🏁 **HTTP Response to Teams**: `200` (total ⏱️ 3034ms) + +### Step 5 — Teams sends signin/tokenExchange invoke + +📥 **INCOMING** `POST http://localhost:3978/api/messages` (2731 bytes) +- **Activity**: + - `type`: `invoke` + - `name`: `signin/tokenExchange` + - `id`: `f:9b40df9c-b27c-55a0-7b42-0d2033f7d213` + - `channelId`: `msteams` + - `timestamp`: `2026-04-22T02:45:29.991Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.name`: `Rido` + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `recipient.name`: `my-bot-sso` + - `conversation.id`: `a:1xH4HncZ6ly...OIM3Z` + - `conversation.conversationType`: `personal` + - `conversation.tenantId`: `3f3d1cea-7a18-41af-872b-cfbbd5140984` + - `serviceUrl`: `https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/` + - `channelData.source.name`: `message` + - `value`: + - `id`: `fc67c7b5-d0d4-494c-a0e9-3a7ddec999f0` *(matches tokenExchangeResource.Id from OAuthCard)* + - `connectionName`: `sso` + - `token`: SSO JWT (`aud=e3cb1c84...`, `iss=login.microsoftonline.com`, `name=Rido`, `scp=access_as_user`, `preferred_username=rido@teamssdk.onmicrosoft.com`) + - `MSCV`: `M1mwQ79zSkClUOfTm5O0ew.1.2.1.423058522.1.1.0.1.1.0.1.3` +- 🛡️ JWT validated (AzureAd scheme) +- 🔀 Route: `invoke/signin/tokenExchange` + +### Step 6 — Exchange SSO token for user token + +📤 **OUTGOING** `POST https://token.botframework.com/api/usertoken/exchange` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `sso` + - `channelId`: `msteams` +- **Request Body**: `{ "token": "" }` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- ✅ **Response**: `200` (⏱️ 903ms) — user token returned + +### Step 7 — 🎉 OnSignInComplete fires, bot sends confirmation + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/f:9b40df9c-b27c-55a0-7b42-0d2033f7d213` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "f:9b40df9c-b27c-55a0-7b42-0d2033f7d213", + "text": "You're now signed in! Try `profile` or `calendar`.", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` (⏱️ 366ms) + +🏁 **Invoke Response**: `200` (body: null) +🏁 **HTTP Response to Teams**: `200` (total ⏱️ 1308ms) + +--- + +## 👤 Profile Flow (token cached) + +### Step 8 — User sends "profile" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` (1019 bytes) +- **Activity**: + - `type`: `message` + - `id`: `1776825937933` + - `text`: `"profile"` + - `timestamp`: `2026-04-22T02:45:37.9548075Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `MSCV`: `wqMomZDl5k2Mdw7S3YUAsQ.1.1.1.423403741.1.1` +- 🔀 Route: `message/(?i)^profile$` + +### Step 9 — Silent token check (token exists) + +📤 **OUTGOING** `GET https://token.botframework.com/api/usertoken/GetToken` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `sso` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- ✅ **Response**: `200` (⏱️ 214ms) — cached user token returned + +### Step 10 — Call Graph API with token + +📤 **OUTGOING** `GET https://graph.microsoft.com/v1.0/me` +- **Auth**: `Authorization: Bearer {user_token}` +- ✅ **Response**: `200` — `{ displayName: "Rido", mail: "rido@teamssdk.onmicrosoft.com", id: "03500558-e554-416c-90c3-a061cdcd012b" }` + +### Step 11 — Send profile result + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776825937933` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776825937933", + "text": "```json\n{\"@odata.context\":\"...\",\"displayName\":\"Rido\",\"givenName\":\"Rido\",\"jobTitle\":\"Not an architect\",\"mail\":\"rido@teamssdk.onmicrosoft.com\",...}\n```", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` (⏱️ 283ms) + +🏁 **HTTP Response to Teams**: `200` (total ⏱️ 664ms) + +--- + +## 🚪 Logout Flow + +### Step 12 — User sends "logout" message + +📥 **INCOMING** `POST http://localhost:3978/api/messages` (1018 bytes) +- **Activity**: + - `type`: `message` + - `id`: `1776825945288` + - `text`: `"logout"` + - `timestamp`: `2026-04-22T02:45:45.3792484Z` + - `from.id`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `from.aadObjectId`: `03500558-e554-416c-90c3-a061cdcd012b` + - `recipient.id`: `28:e3cb1c84-14e3-419c-b39c-1c06097b55fd` *(Bot MRI)* + - `MSCV`: `xflMC1y26keiHnFL8vvL7g.1.1.1.423642628.1.1` +- 🔀 Route: `message/(?i)^logout$` + +### Step 13 — Sign out user + +📤 **OUTGOING** `DELETE https://token.botframework.com/api/usertoken/SignOut` +- **Query Parameters**: + - `userid`: `29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ` *(User MRI)* + - `connectionName`: `sso` + - `channelId`: `msteams` +- **Request Body**: `(null)` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- ✅ **Response**: `200` (⏱️ 313ms) — token revoked + +### Step 14 — Send confirmation + +📤 **OUTGOING** `POST https://smba.trafficmanager.net/amer/.../v3/conversations/a%3A1xH4HncZ6ly...OIM3Z/activities/1776825945288` +- **Auth**: 🔑 MSAL from cache (⏱️ 0ms) +- **Request Body**: + ```json + { + "from": { "id": "28:e3cb1c84-14e3-419c-b39c-1c06097b55fd", "name": "my-bot-sso" }, + "conversation": { "tenantId": "3f3d1cea-...", "conversationType": "personal", "id": "a:1xH4HncZ6ly...OIM3Z" }, + "type": "message", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/3f3d1cea-7a18-41af-872b-cfbbd5140984/", + "replyToId": "1776825945288", + "text": "Signed out.", + "textFormat": "plain" + } + ``` +- ✅ **Response**: `201 Created` (⏱️ 339ms) + +🏁 **HTTP Response to Teams**: `200` (total ⏱️ 662ms) + +--- + +## 📊 Request Summary Table + +| # | Direction | Method | Endpoint | Status | Latency | Purpose | +|---|-----------|--------|----------|--------|---------|---------| +| 1 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | ⏱️ 3034ms | 💬 "login" message | +| 2 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ❌ 404 | ⏱️ 568ms | 🔍 Silent token check (miss) | +| 3 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/botsignin/GetSignInResource` | ✅ 200 | ⏱️ 286ms | 🔗 Get sign-in resource | +| 4 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 202 | ⏱️ 631ms | 🃏 Send OAuthCard | +| 5 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | ⏱️ 1308ms | 🔄 signin/tokenExchange invoke | +| 6 | 📤 ⬆️ OUT | POST | `token.botframework.com/api/usertoken/exchange` | ✅ 200 | ⏱️ 903ms | 🔐 SSO token exchange | +| 7 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | ⏱️ 366ms | 🎉 "Signed in!" confirmation | +| 8 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | ⏱️ 664ms | 💬 "profile" message | +| 9 | 📤 ⬆️ OUT | GET | `token.botframework.com/api/usertoken/GetToken` | ✅ 200 | ⏱️ 214ms | 🔍 Silent token check (hit) | +| 10 | 📤 ⬆️ OUT | GET | `graph.microsoft.com/v1.0/me` | ✅ 200 | - | 👤 Graph API call | +| 11 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | ⏱️ 283ms | 📄 Profile response | +| 12 | 📥 ⬇️ IN | POST | `/api/messages` | ✅ 200 | ⏱️ 662ms | 💬 "logout" message | +| 13 | 📤 ⬆️ OUT | DELETE | `token.botframework.com/api/usertoken/SignOut` | ✅ 200 | ⏱️ 313ms | 🚪 Revoke token | +| 14 | 📤 ⬆️ OUT | POST | `smba.trafficmanager.net/.../activities` | ✅ 201 | ⏱️ 339ms | 💬 "Signed out." confirmation | + +## 🆔 User MRI Usage Across Requests + +| Request | Where User MRI appears | Format | +|---------|----------------------|--------| +| Step 1 (incoming message) | `activity.from.id` | `29:1cgsv1oFLAoTflZ-...` | +| Step 2 (GetToken) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 3 (GetSignInResource) | `state.Conversation.User.Id` (base64 JSON) | `29:1cgsv1oFLAoTflZ-...` | +| Step 4 (Send OAuthCard) | `recipient.id` (reply to user) | `29:1cgsv1oFLAoTflZ-...` | +| Step 5 (tokenExchange invoke) | `activity.from.id` | `29:1cgsv1oFLAoTflZ-...` | +| Step 6 (Exchange token) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 9 (GetToken cached) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | +| Step 13 (SignOut) | `?userid=` query param | URL-encoded: `29%3A1cgsv1oFLAoTflZ-...` | + +> **Note**: The User MRI (`29:...`) is the Teams-specific identifier. It is used as `userid` in all Token Bot Service calls (GetToken, Exchange, SignOut) and appears in `from.id` on incoming activities and `recipient.id` on outgoing replies. The AAD ObjectId (`03500558-...`) appears separately in `from.aadObjectId` and in the outgoing `recipient.aadObjectId`. + +## 🔑 MSAL Token Acquisitions + +| # | Time | Source | Duration | Scope | +|---|------|--------|----------|-------| +| 1 | 02:45:27Z | 🌐 IdentityProvider | ⏱️ 535ms | `api.botframework.com/.default` | +| 2-14 | 02:45:28-46Z | 💾 Cache | ⏱️ 0ms | `api.botframework.com/.default` | + +First acquisition hit AAD (instance discovery + token POST). All subsequent acquisitions served from in-memory MSAL cache. diff --git a/core/samples/AFBot/AFBot.csproj b/core/samples/AFBot/AFBot.csproj new file mode 100644 index 000000000..f9700c130 --- /dev/null +++ b/core/samples/AFBot/AFBot.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/core/samples/AFBot/DropTypingMiddleware.cs b/core/samples/AFBot/DropTypingMiddleware.cs new file mode 100644 index 000000000..46c33cbdd --- /dev/null +++ b/core/samples/AFBot/DropTypingMiddleware.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core; +using Microsoft.Teams.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 000000000..fe7204a3a --- /dev/null +++ b/core/samples/AFBot/Program.cs @@ -0,0 +1,68 @@ +// 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.Extensions.AI; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; +using Microsoft.Teams.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.UseMiddleware(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) + .WithServiceUrl(activity.ServiceUrl!) + .WithChannelId(activity.ChannelId!) + .WithConversation(activity.Conversation!) + .WithFrom(activity.Recipient) + .Build(); + await botApp.SendActivityAsync(typing, cancellationToken: cancellationToken); + + AgentRunResponse agentResponse = await agent.RunAsync(activity.Properties["text"]?.ToString() ?? "OMW", cancellationToken: timer.Token); + + ChatMessage? m1 = agentResponse.Messages.FirstOrDefault(); + Console.WriteLine($"AI:: GOT {agentResponse.Messages.Count} msgs"); + CoreActivity replyActivity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithServiceUrl(activity.ServiceUrl!) + .WithChannelId(activity.ChannelId!) + .WithConversation(activity.Conversation!) + .WithFrom(activity.Recipient) + .WithProperty("text", m1!.Text) + .Build(); + + SendActivityResponse? res = await botApp.SendActivityAsync(replyActivity, cancellationToken: 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 000000000..1ff8c1353 --- /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 000000000..af852491c --- /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 000000000..e81f1fd69 --- /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 000000000..5efd1452d --- /dev/null +++ b/core/samples/AllFeatures/Program.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.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 000000000..10f68b8c8 --- /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/AllInvokesBot/AllInvokesBot.csproj b/core/samples/AllInvokesBot/AllInvokesBot.csproj new file mode 100644 index 000000000..edd383c6e --- /dev/null +++ b/core/samples/AllInvokesBot/AllInvokesBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/AllInvokesBot/Cards.cs b/core/samples/AllInvokesBot/Cards.cs new file mode 100644 index 000000000..6a9c6f311 --- /dev/null +++ b/core/samples/AllInvokesBot/Cards.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace AllInvokesBot; + +public static class Cards +{ + public static JsonObject CreateWelcomeCard() + { + return new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Welcome to InvokesBot!", + ["size"] = "Large", + ["weight"] = "Bolder" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Click the buttons below to test different invoke handlers:" + } + }, + ["actions"] = new JsonArray + { + new JsonObject + { + ["type"] = "Action.Execute", + ["id"] = "1234", + ["title"] = "Test Adaptive Card Action", + ["verb"] = "testAction", + ["data"] = new JsonObject + { + ["message"] = "Button clicked!" + } + }, + new JsonObject + { + ["type"] = "Action.Submit", + ["title"] = "Open Task Module", + ["data"] = new JsonObject + { + ["msteams"] = new JsonObject + { + ["type"] = "task/fetch" + } + } + }, + new JsonObject + { + ["type"] = "Action.Execute", + ["title"] = "Request File Upload", + ["verb"] = "requestFileUpload" + } + } + }; + } + + public static JsonObject CreateFileConsentCard() + { + return new JsonObject + { + ["description"] = "This is a sample file to demonstrate file consent", + ["sizeInBytes"] = 1024, + ["acceptContext"] = new JsonObject + { + ["fileId"] = "123456" + }, + ["declineContext"] = new JsonObject + { + ["fileId"] = "123456" + } + }; + } + + public static JsonObject CreateAdaptiveActionResponseCard(string? verb, string? message) + { + return new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"Action '{verb}' executed", + ["weight"] = "Bolder" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"Message: {message}", + ["wrap"] = true + } + } + }; + } + + public static JsonObject CreateTaskModuleCard() + { + return new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Task Module" + } + }, + ["actions"] = new JsonArray + { + new JsonObject + { + ["type"] = "Action.Submit", + ["title"] = "Submit" + } + } + }; + } + + public static JsonObject CreateFileInfoCard(string? uniqueId, string? fileType) + { + return new JsonObject + { + ["uniqueId"] = uniqueId, + ["fileType"] = fileType + }; + } +} diff --git a/core/samples/AllInvokesBot/Program.cs b/core/samples/AllInvokesBot/Program.cs new file mode 100644 index 000000000..501246ef5 --- /dev/null +++ b/core/samples/AllInvokesBot/Program.cs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using AllInvokesBot; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Handlers.TaskModules; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Hosting; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication bot = webApp.UseBotApplication(); + +// ==================== MESSAGE - SEND SIMPLE CARD ==================== +bot.OnMessage(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnMessage"); + + JsonObject card = Cards.CreateWelcomeCard(); + + TeamsAttachment attachment = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(card) + .Build(); + + await context.SendActivityAsync(new MessageActivity([attachment]), cancellationToken); +}); + +// ==================== ADAPTIVE CARD ACTION ==================== +bot.OnAdaptiveCardAction(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnAdaptiveCardAction"); + AdaptiveCardActionValue? value = context.Activity.Value; + AdaptiveCardAction? action = value?.Action; + string? verb = action?.Verb; + Dictionary? data = action?.Data; + + Console.WriteLine($" Verb: {verb}"); + Console.WriteLine($" Data: {JsonSerializer.Serialize(data)}"); + + // Handle file upload request + if (verb == "requestFileUpload") + { + JsonObject fileConsentCard = Cards.CreateFileConsentCard(); + TeamsAttachment fileConsentCardResponse = TeamsAttachment.CreateBuilder() + .WithContent(fileConsentCard).WithContentType(AttachmentContentType.FileConsentCard) + .WithName("file_consent.json").Build(); + await context.SendActivityAsync(new MessageActivity([fileConsentCardResponse]), cancellationToken); + + return AdaptiveCardResponse.CreateMessageResponse("File Consent requested!"); + } + + string? message = data != null && data.TryGetValue("message", out object? msgValue) ? msgValue?.ToString() : null; + + JsonObject adaptiveActionCard = Cards.CreateAdaptiveActionResponseCard(verb, message); + TeamsAttachment adaptiveActionCardResponse = TeamsAttachment.CreateBuilder().WithAdaptiveCard(adaptiveActionCard).Build(); + await context.SendActivityAsync(new MessageActivity([adaptiveActionCardResponse]), cancellationToken); + + return AdaptiveCardResponse.CreateMessageResponse("Action submitted!"); +}); + +// ==================== TASK MODULE - FETCH ==================== +bot.OnTaskFetch(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnTaskFetch"); + TeamsAttachment taskModuleCardResponse = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.CreateTaskModuleCard()).Build(); + return TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Task") + .WithHeight("medium") + .WithWidth("medium") + .WithCard(taskModuleCardResponse) + .Build(); + +}); + +// ==================== TASK MODULE - SUBMIT ==================== +bot.OnTaskSubmit(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnTaskSubmit"); + return TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Message) + .WithMessage("Done") + .Build(); +}); + +// ==================== FILE CONSENT ==================== +bot.OnFileConsent(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnFileConsent"); + + FileConsentValue? value = context.Activity.Value; + string? action = value?.Action; + FileUploadInfo? uploadInfo = value?.UploadInfo; + object? consentContext = value?.Context; + + if (action == "accept") + { + Console.WriteLine($" File accepted!"); + + // Upload the file + string? uploadUrl = uploadInfo?.UploadUrl?.ToString(); + string? fileName = uploadInfo?.Name; + string? contentUrl = uploadInfo?.ContentUrl?.ToString(); + string? uniqueId = uploadInfo?.UniqueId; + + if (uploadUrl != null && contentUrl != null) + { + // Create sample file content + string fileContent = "This is a sample file uploaded via file consent!"; + byte[] fileBytes = System.Text.Encoding.UTF8.GetBytes(fileContent); + int fileSize = fileBytes.Length; + + using HttpClient httpClient = new(); + using ByteArrayContent content = new(fileBytes); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + content.Headers.ContentRange = new System.Net.Http.Headers.ContentRangeHeaderValue(0, fileSize - 1, fileSize); + + try + { + HttpResponseMessage uploadResponse = await httpClient.PutAsync(uploadUrl, content, cancellationToken); + Console.WriteLine($" Upload Status: {uploadResponse.StatusCode}"); + + if (uploadResponse.IsSuccessStatusCode) + { + JsonObject fileInfoContent = Cards.CreateFileInfoCard(uniqueId, uploadInfo?.FileType); + + TeamsAttachment fileUploadResponse = TeamsAttachment.CreateBuilder() + .WithName(fileName) + .WithContentType(AttachmentContentType.FileInfoCard) + .WithContentUrl(contentUrl != null ? new Uri(contentUrl) : null) + .WithContent(fileInfoContent).Build(); + + await context.SendActivityAsync(new MessageActivity([fileUploadResponse]), cancellationToken); + } + else + { + Console.WriteLine($" File upload failed: {await uploadResponse.Content.ReadAsStringAsync(cancellationToken)}"); + } + } + catch (Exception ex) + { + Console.WriteLine($" File upload error: {ex.Message}"); + } + } + } + else if (action == "decline") + { + Console.WriteLine($" File declined!"); + Console.WriteLine($" Context: {JsonSerializer.Serialize(consentContext)}"); + } + + return AdaptiveCardResponse.CreateBuilder() + .WithStatusCode(200) + .Build(); +}); + +/* +// ==================== EXECUTE ACTION ==================== +bot.OnExecuteAction(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnExecuteAction"); + + var responseBody = new JsonObject + { + ["status"] = "completed" + }; + + return new CoreInvokeResponse(200, responseBody); +}); + +// ==================== HANDOFF ==================== +bot.OnHandoff(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnHandoff"); + return new CoreInvokeResponse(200); +}); + +// ==================== SEARCH ==================== +bot.OnSearch(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnSearch"); + + var responseBody = new JsonObject + { + ["results"] = new JsonArray + { + new JsonObject + { + ["id"] = "1", + ["title"] = "Result" + } + } + }; + + return new CoreInvokeResponse(200, responseBody); +}); + +// ==================== MESSAGE SUBMIT ACTION ==================== +bot.OnMessageSubmitAction(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnMessageSubmitAction"); + + var data = context.Activity.Value; + Console.WriteLine($" Data: {System.Text.Json.JsonSerializer.Serialize(data)}"); + + // Extract data fields + var jsonData = System.Text.Json.JsonSerializer.Deserialize( + System.Text.Json.JsonSerializer.Serialize(data)); + + string? action = jsonData.TryGetProperty("action", out var a) ? a.GetString() : "unknown"; + string? value = jsonData.TryGetProperty("value", out var v) ? v.GetString() : "no value"; + + Console.WriteLine($" Action: {action}"); + Console.WriteLine($" Value: {value}"); + + var responseBody = new JsonObject + { + ["statusCode"] = 200, + ["type"] = "application/vnd.microsoft.activity.message", + ["value"] = $"Message action '{action}' submitted! Value: {value}" + }; + + return new CoreInvokeResponse(200, responseBody); +}); + +// ==================== CONFIG FETCH ==================== +bot.OnConfigFetch(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnConfigFetch"); + + var card = new + { + contentType = AttachmentContentType.AdaptiveCard, + content = new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = "Extension Settings", size = "large", weight = "bolder" }, + new { type = "TextBlock", text = "Configure your messaging extension settings below:", wrap = true }, + new { type = "Input.Text", id = "apiKey", label = "API Key", placeholder = "Enter your API key" }, + new { type = "Input.Toggle", id = "enableNotifications", label = "Enable Notifications", value = "true" } + }, + actions = new object[] + { + new { type = "Action.Submit", title = "Save Settings" } + } + } + }; + + var response = TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Configure Messaging Extension") + .WithHeight(TaskModuleSize.Medium) + .WithWidth(TaskModuleSize.Medium) + .WithCard(card) + .Build(); + + return new CoreInvokeResponse(200, response); +}); + +// ==================== CONFIG SUBMIT ==================== +bot.OnConfigSubmit(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnConfigSubmit"); + + var data = context.Activity.Value; + Console.WriteLine($" Config data: {System.Text.Json.JsonSerializer.Serialize(data)}"); + + // In a real app, you would save these settings to a database + // associated with the user/team + + var response = TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Message) + .WithMessage("Settings saved successfully!") + .Build(); + + return new CoreInvokeResponse(200, response); +}); +*/ + +webApp.Run(); diff --git a/core/samples/AllInvokesBot/README.md b/core/samples/AllInvokesBot/README.md new file mode 100644 index 000000000..bcb0805ef --- /dev/null +++ b/core/samples/AllInvokesBot/README.md @@ -0,0 +1,52 @@ +# AllInvokesBot Testing Guide + +A sample bot demonstrating Teams invoke handlers. + +## Setup + +1. Configure bot credentials in `appsettings.json` or environment variables +2. Run the bot: `dotnet run` +3. Upload `manifest.json` to Teams + +## Testing Handlers + +### OnMessage +**Manifest:** `bots` section with appropriate `scopes` (personal, team, groupChat) + +1. Send any message to the bot in 1:1 chat +2. Verify welcome card with action buttons appears + +### OnAdaptiveCardAction +**Manifest:** No specific requirement (triggered by adaptive card actions) + +1. After receiving the welcome card +2. Click any action button on the card +3. Verify action response card appears +4. Console logs will show the verb and data + +**File Upload Flow:** +1. Click "Request File Upload" button +2. Verify file consent card appears + +### OnFileConsent +**Manifest:** `bots.supportsFiles: true` +**Azure:** Delegated permission `Files.ReadWrite.All` required in Azure app registration + +1. After requesting file upload (see above) +2. Click Accept or Decline on the file consent card +3. If Accept - verify file uploads and file info card appears +4. If Decline - verify console logs the decline action + +### OnTaskFetch +**Manifest:** No specific requirement (triggered by task module actions) + +1. Click "Open Task Module" button on the welcome card +2. Verify task module dialog opens with input form + +### OnTaskSubmit +**Manifest:** No specific requirement (works with OnTaskFetch) + +1. Open task module (see OnTaskFetch) +2. Fill in the form +3. Click submit +4. Verify "Done" message appears diff --git a/core/samples/AllInvokesBot/appsettings.json b/core/samples/AllInvokesBot/appsettings.json new file mode 100644 index 000000000..5febf4fe3 --- /dev/null +++ b/core/samples/AllInvokesBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/AllInvokesBot/manifest.json b/core/samples/AllInvokesBot/manifest.json new file mode 100644 index 000000000..53406f9e0 --- /dev/null +++ b/core/samples/AllInvokesBot/manifest.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.24/MicrosoftTeams.schema.json", + "version": "1.0.0", + "manifestVersion": "1.24", + "id": "YOUR_BOT_ID", + "name": { + "short": "YOUR_BOT_NAME", + "full": "YOUR_BOT_NAME" + }, + "developer": { + "name": "Microsoft", + "mpnId": "", + "websiteUrl": "https://microsoft.com", + "privacyUrl": "https://privacy.microsoft.com/privacystatement", + "termsOfUseUrl": "https://www.microsoft.com/legal/terms-of-use" + }, + "description": { + "short": "YOUR_BOT_NAME", + "full": "YOUR_BOT_NAME" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "staticTabs": [ + { + "entityId": "conversations", + "scopes": [ + "personal" + ] + }, + { + "entityId": "about", + "scopes": [ + "personal" + ] + } + ], + "bots": [ + { + "botId": "YOUR_BOT_ID", + "scopes": [ + "personal", + "team", + "groupChat" + ], + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "supportsFiles": true + } + ], + "validDomains": [ + "*.microsoft.com" + ], + "webApplicationInfo": { + "id": "YOUR_BOT_ID", + "resource": "https://graph.microsoft.com" + } +} diff --git a/core/samples/CompatBot/Cards.cs b/core/samples/CompatBot/Cards.cs new file mode 100644 index 000000000..4ba0be86e --- /dev/null +++ b/core/samples/CompatBot/Cards.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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 000000000..03381009e --- /dev/null +++ b/core/samples/CompatBot/CompatBot.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/core/samples/CompatBot/EchoBot.cs b/core/samples/CompatBot/EchoBot.cs new file mode 100644 index 000000000..9efb06d9e --- /dev/null +++ b/core/samples/CompatBot/EchoBot.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Apps.BotBuilder; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace CompatBot; + +public class ConversationData +{ + public int MessageCount { get; set; } = 0; + +} + +internal class EchoBot(BotApplication 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); + + var mm = await TeamsApiClient.GetMemberAsync(turnContext, turnContext.Activity.From.Id); + string replyText = $"Echo {mm.Name} from BF Compat [{conversationData.MessageCount++}]: {turnContext.Activity.Text}"; + + // Targeted Messaging via BF compat layer: setting isTargeted on the BF ChannelAccount + // causes the compat layer to set CoreActivity.Recipient.IsTargeted, which appends + // ?isTargetedActivity=true to the URL making the message visible only to that user. + var act = MessageFactory.Text(replyText, replyText); + act.Recipient = new ChannelAccount(); + act.Recipient.Properties.Add("isTargeted", true); + await turnContext.SendActivityAsync(act, cancellationToken); + + + if (turnContext.Activity.Conversation.IsGroup == true) + { + var teamDetails = await TeamsApiClient.GetTeamDetailsAsync(turnContext, null, cancellationToken); + await turnContext.SendActivityAsync(JsonConvert.SerializeObject(teamDetails, Formatting.Indented)); + + TeamsPagedMembersResult pagedMembersResult; + List members = new List(); + string continuationToken = null!; + do + { + pagedMembersResult = await TeamsApiClient.GetPagedMembersAsync( + turnContext, + 5, + continuationToken, + cancellationToken + ); + + continuationToken = pagedMembersResult.ContinuationToken; + members.AddRange(pagedMembersResult.Members); + } while (continuationToken != null); + + await turnContext.SendActivityAsync(JsonConvert.SerializeObject(members.Select(m => m.Name).ToList(), Formatting.Indented)); + } + + // Targeted Messaging via Core SDK (preferred): sends directly through ConversationClient + // to bypass the BF compat layer's ApplyConversationReference which would overwrite the Recipient. + var incomingCoreActivity = ((Activity)turnContext.Activity).FromBotFrameworkActivity(); + var incomingFrom = incomingCoreActivity.From; + var incomingRecipient = incomingCoreActivity.Recipient; + incomingFrom!.IsTargeted = true; + CoreActivity tm = CoreActivity.CreateBuilder() + .WithConversation(incomingCoreActivity.Conversation!) + .WithProperty("text", "Hello TM !") + .WithRecipient(incomingFrom) + .WithFrom(incomingRecipient) + //.WithServiceUrl(activity.ServiceUrl!) + .WithServiceUrl("https://pilot1.botapi.skype.com/amer/9a9b49fd-1dc5-4217-88b3-ecf855e91b0e/") + .Build(); + + await teamsBotApp.ConversationClient.SendActivityAsync(tm, cancellationToken: cancellationToken); + + var res = await turnContext.SendActivityAsync( + MessageFactory.Text("I'm going to add and remove reactions to this message."), cancellationToken); + + await Task.Delay(500, cancellationToken); + + await teamsBotApp.ConversationClient.AddReactionAsync( + turnContext.Activity.Conversation.Id, + res.Id, + "laugh", + new Uri("https://pilot1.botapi.skype.com/amer/9a9b49fd-1dc5-4217-88b3-ecf855e91b0e/"), + //incomingCoreActivity.ServiceUrl!, + AgenticIdentity.FromAccount(incomingRecipient), + null, + cancellationToken); + + await Task.Delay(500, cancellationToken); + + await teamsBotApp.ConversationClient.AddReactionAsync( + turnContext.Activity.Conversation.Id, + res.Id, + "sad", + incomingCoreActivity.ServiceUrl!, + AgenticIdentity.FromAccount(incomingRecipient), + null, + cancellationToken); + + await Task.Delay(500, cancellationToken); + + await teamsBotApp.ConversationClient.DeleteReactionAsync( + turnContext.Activity.Conversation.Id, + res.Id, + "laugh", + //new Uri("https://pilot1.botapi.skype.com/amer/9a9b49fd-1dc5-4217-88b3-ecf855e91b0e/"), + incomingCoreActivity.ServiceUrl!, + AgenticIdentity.FromAccount(incomingRecipient), + null, + cancellationToken); + + // Card submission triggers OnInvokeActivityAsync below. + Attachment attachment = new() + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = Cards.FeedbackCardObj + }; + IMessageActivity 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); + JObject actionValue = JObject.FromObject(turnContext.Activity.Value); + JObject? action = actionValue["action"] as JObject; + JObject? actionData = action?["data"] as JObject; + string? userInput = actionData?["feedback"]?.ToString(); + //var userInput = actionValue["userInput"]?.ToString(); + + logger.LogInformation("Action: {Action}, User Input: {UserInput}", action, userInput); + + + + Attachment attachment = new() + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = Cards.ResponseCard(userInput) + }; + + IMessageActivity card = MessageFactory.Attachment(attachment); + await turnContext.SendActivityAsync(card, cancellationToken); + + return new Microsoft.Bot.Builder.InvokeResponse + { + Status = 200, + Body = new { value = "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, CancellationToken cancellationToken) + { + ConversationReference 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."; + + ResourceResponse[] res = await turnContext.Adapter.SendActivitiesAsync(turnContext, [reply], cancellationToken); + + await Task.Delay(2000, cancellationToken); + + Activity updatedActivity = (Activity)Activity.CreateMessageActivity(); + updatedActivity.ApplyConversationReference(cr, isIncoming: false); + updatedActivity.Id = res[0].Id; + updatedActivity.Text = "This message has been updated."; + + await turnContext.Adapter.UpdateActivityAsync(turnContext, updatedActivity, cancellationToken); + + await Task.Delay(2000, cancellationToken); + + ConversationReference deleteReference = cr; + deleteReference.ActivityId = res[0].Id; + await turnContext.Adapter.DeleteActivityAsync(turnContext, deleteReference, 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 000000000..ef87a97ea --- /dev/null +++ b/core/samples/CompatBot/MyCompatMiddleware.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; + +namespace CompatBot +{ + public class MyCompatMiddleware : Microsoft.Bot.Builder.IMiddleware + { + public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + Console.WriteLine("MyCompatMiddleware: OnTurnAsync"); + Console.WriteLine(turnContext.Activity.Text); + + await turnContext.SendActivityAsync(MessageFactory.Text("Hello from MyCompatMiddleware!"), cancellationToken); + + await next(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/core/samples/CompatBot/Program.cs b/core/samples/CompatBot/Program.cs new file mode 100644 index 000000000..b7d142531 --- /dev/null +++ b/core/samples/CompatBot/Program.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + + +using CompatBot; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Schema; +using Microsoft.Teams.Apps.BotBuilder; +using Microsoft.Teams.Core; + +// using Microsoft.Bot.Connector.Authentication; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.AddTeamsBotFrameworkHttpAdapter(); + +//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(); + +TeamsBotFrameworkHttpAdapter compatAdapter = (TeamsBotFrameworkHttpAdapter)app.Services.GetRequiredService(); +compatAdapter.Use(new MyCompatMiddleware()); +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)).RequireAuthorization(); + +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 ((BotAdapter)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 000000000..1ff8c1353 --- /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/CompatProactive/CompatProactive.csproj b/core/samples/CompatProactive/CompatProactive.csproj new file mode 100644 index 000000000..3dcbabddc --- /dev/null +++ b/core/samples/CompatProactive/CompatProactive.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + false + enable + + + + + + + + + + + + diff --git a/core/samples/CompatProactive/ProactiveWorker.cs b/core/samples/CompatProactive/ProactiveWorker.cs new file mode 100644 index 000000000..1c4a724ec --- /dev/null +++ b/core/samples/CompatProactive/ProactiveWorker.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Apps.BotBuilder; + +namespace CompatProactive; + +internal class ProactiveWorker(IBotFrameworkHttpAdapter compatAdapter, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + ConversationReference conversationReference = new() + { + ServiceUrl = "https://smba.trafficmanager.net/teams/", + Conversation = new() { Id = "19:ad37a1f8af5549e3b81edf249fe5cb1b@thread.tacv2" }, + }; + + await ((TeamsBotFrameworkHttpAdapter)compatAdapter).ContinueConversationAsync("", conversationReference, callback, stoppingToken); + logger.LogInformation("Proactive message sent"); + } + + private async Task callback(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivitiesAsync(new Activity[] + { + MessageFactory.Text($"Proactive with Compat Layer {DateTimeOffset.Now}") + }, cancellationToken); + } +} diff --git a/core/samples/CompatProactive/Program.cs b/core/samples/CompatProactive/Program.cs new file mode 100644 index 000000000..934f182b8 --- /dev/null +++ b/core/samples/CompatProactive/Program.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using CompatProactive; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Teams.Apps.BotBuilder; + + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddTeamsBotFrameworkHttpAdapter(); +builder.Services.AddHostedService(); +IHost host = builder.Build(); +host.Run(); diff --git a/core/samples/CompatProactive/appsettings.json b/core/samples/CompatProactive/appsettings.json new file mode 100644 index 000000000..8a27e2534 --- /dev/null +++ b/core/samples/CompatProactive/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Teams": "Trace" + } + } +} diff --git a/core/samples/CoreBot/CoreBot.csproj b/core/samples/CoreBot/CoreBot.csproj new file mode 100644 index 000000000..23434add5 --- /dev/null +++ b/core/samples/CoreBot/CoreBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/CoreBot/Program.cs b/core/samples/CoreBot/Program.cs new file mode 100644 index 000000000..d893192dc --- /dev/null +++ b/core/samples/CoreBot/Program.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; +using Microsoft.Teams.Core.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +webApp.MapGet("/", () => "CoreBot is running."); +BotApplication botApp = webApp.UseBotApplication(); + +botApp.OnActivity = async (activity, cancellationToken) => +{ + string replyText = $"CoreBot running on SDK `{BotApplication.Version}`."; + ArgumentNullException.ThrowIfNull(activity.Conversation); + CoreActivity replyActivity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithChannelId(activity.ChannelId) + .WithServiceUrl(activity.ServiceUrl) + .WithConversation(activity.Conversation) + .WithFrom(activity.Recipient) + .WithProperty("text", replyText) + .Build(); + + await botApp.SendActivityAsync(replyActivity, cancellationToken: cancellationToken); +}; + +webApp.Run(); diff --git a/core/samples/CoreBot/appsettings.json b/core/samples/CoreBot/appsettings.json new file mode 100644 index 000000000..396e887e5 --- /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.Teams": "Trace" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/CustomHosting/CustomHosting.csproj b/core/samples/CustomHosting/CustomHosting.csproj new file mode 100644 index 000000000..edd383c6e --- /dev/null +++ b/core/samples/CustomHosting/CustomHosting.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/CustomHosting/MyTeamsBotApp.cs b/core/samples/CustomHosting/MyTeamsBotApp.cs new file mode 100644 index 000000000..f2498d401 --- /dev/null +++ b/core/samples/CustomHosting/MyTeamsBotApp.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; + +namespace CustomHosting; + +public class MyTeamsBotApp : TeamsBotApplication +{ + public MyTeamsBotApp(ConversationClient conversationClient, UserTokenClient userTokenClient, ApiClient teamsApiClient, IHttpContextAccessor httpContextAccessor, ILogger logger, BotApplicationOptions? options = null) : base(conversationClient, userTokenClient, teamsApiClient, httpContextAccessor, logger, options) + { + this.OnMessage(async (ctx, ct) => + { + await ctx.SendActivityAsync("Hello from MyTeamsBotApp!", ct); + }); + } +} diff --git a/core/samples/CustomHosting/Program.cs b/core/samples/CustomHosting/Program.cs new file mode 100644 index 000000000..fa46c75c2 --- /dev/null +++ b/core/samples/CustomHosting/Program.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using CustomHosting; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Core.Hosting; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); + +// TODO: Show how to setup multiple Teams Bot applications (like how it was done in PABot) +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +webApp.MapGet("/", () => $"Teams Bot App is running {TeamsBotApplication.Version}."); +webApp.UseBotApplication(); + +webApp.Run(); diff --git a/core/samples/CustomHosting/appsettings.json b/core/samples/CustomHosting/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/core/samples/CustomHosting/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/Directory.Build.props b/core/samples/Directory.Build.props new file mode 100644 index 000000000..f4880b177 --- /dev/null +++ b/core/samples/Directory.Build.props @@ -0,0 +1,6 @@ + + + all + true + + diff --git a/core/samples/MeetingsBot/MeetingsBot.csproj b/core/samples/MeetingsBot/MeetingsBot.csproj new file mode 100644 index 000000000..edd383c6e --- /dev/null +++ b/core/samples/MeetingsBot/MeetingsBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/MeetingsBot/Program.cs b/core/samples/MeetingsBot/Program.cs new file mode 100644 index 000000000..93899d274 --- /dev/null +++ b/core/samples/MeetingsBot/Program.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); + +// ==================== MEETING HANDLERS ==================== + +teamsApp.OnMeetingStart(async (context, cancellationToken) => +{ + MeetingStartValue? meeting = context.Activity.Value; + Console.WriteLine($"[MeetingStart] Title: {meeting?.Title}"); + await context.SendActivityAsync($"Meeting started: **{meeting?.Title}**", cancellationToken); +}); + +teamsApp.OnMeetingEnd(async (context, cancellationToken) => +{ + MeetingEndValue? meeting = context.Activity.Value; + Console.WriteLine($"[MeetingEnd] Title: {meeting?.Title}, EndTime: {meeting?.EndTime:u}"); + await context.SendActivityAsync($"Meeting ended: **{meeting?.Title}**\nEnd time: {meeting?.EndTime:u}", cancellationToken); +}); + +teamsApp.OnMeetingParticipantJoin(async (context, cancellationToken) => +{ + IList members = context.Activity.Value?.Members ?? []; + string names = string.Join(", ", members.Select(m => m.User.Name ?? m.User.Id)); + Console.WriteLine($"[MeetingParticipantJoin] Members: {names}"); + await context.SendActivityAsync($"Participant(s) joined: {names}", cancellationToken); +}); + +teamsApp.OnMeetingParticipantLeave(async (context, cancellationToken) => +{ + IList members = context.Activity.Value?.Members ?? []; + string names = string.Join(", ", members.Select(m => m.User.Name ?? m.User.Id)); + Console.WriteLine($"[MeetingParticipantLeave] Members: {names}"); + await context.SendActivityAsync($"Participant(s) left: {names}", cancellationToken); +}); + +//TODO : review if we can trigger these +// ==================== COMMAND HANDLERS ==================== +/* + +teamsApp.OnCommand(async (context, cancellationToken) => +{ + var commandId = context.Activity.Value?.CommandId ?? "unknown"; + Console.WriteLine($"[Command] CommandId: {commandId}"); + await context.SendActivityAsync($"Received command: **{commandId}**", cancellationToken); +}); + +teamsApp.OnCommandResult(async (context, cancellationToken) => +{ + var commandId = context.Activity.Value?.CommandId ?? "unknown"; + var error = context.Activity.Value?.Error; + Console.WriteLine($"[CommandResult] CommandId: {commandId}, HasError: {error is not null}"); + + if (error is not null) + await context.SendActivityAsync($"Command **{commandId}** failed: {error.Message}", cancellationToken); + else + await context.SendActivityAsync($"Command **{commandId}** completed successfully.", cancellationToken); +}); +*/ +webApp.Run(); diff --git a/core/samples/MeetingsBot/README.md b/core/samples/MeetingsBot/README.md new file mode 100644 index 000000000..db4b6eafe --- /dev/null +++ b/core/samples/MeetingsBot/README.md @@ -0,0 +1,51 @@ +# Sample: Meetings + +This sample demonstrates how to handle real-time updates for meeting events and meeting participant events. + +## Manifest Requirements + +There are a few requirements in the Teams app manifest (manifest.json) to support these events. + +1) The `scopes` section must include `team`, and `groupChat`: + +```json + "bots": [ + { + "botId": "", + "scopes": [ + "team", + "personal", + "groupChat" + ], + "isNotificationOnly": false + } + ] +``` + +2) In the authorization section, make sure to specify the following resource-specific permissions: + +```json + "authorization":{ + "permissions":{ + "resourceSpecific":[ + { + "name":"OnlineMeetingParticipant.Read.Chat", + "type":"Application" + }, + { + "name":"ChannelMeeting.ReadBasic.Group", + "type":"Application" + }, + { + "name":"OnlineMeeting.ReadBasic.Chat", + "type":"Application" + } + ] + } + } +``` + +### Teams Developer Portal: Bot Configuration + +For your Bot, make sure the [Meeting Event Subscriptions](https://learn.microsoft.com/en-us/microsoftteams/platform/apps-in-teams-meetings/meeting-apps-apis?branch=pr-en-us-8455&tabs=channel-meeting%2Cguest-user%2Cone-on-one-call%2Cdotnet3%2Cdotnet2%2Cdotnet%2Cparticipant-join-event%2Cparticipant-join-event1#receive-meeting-participant-events) are checked. +This enables you to receive the Meeting Participant events. diff --git a/core/samples/MeetingsBot/appsettings.json b/core/samples/MeetingsBot/appsettings.json new file mode 100644 index 000000000..5febf4fe3 --- /dev/null +++ b/core/samples/MeetingsBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/MessageExtensionBot/Cards.cs b/core/samples/MessageExtensionBot/Cards.cs new file mode 100644 index 000000000..e2751529e --- /dev/null +++ b/core/samples/MessageExtensionBot/Cards.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace MessageExtensionBot; + +public static class Cards +{ + public static object[] CreateQueryResultCards(string searchText) + { + return new[] + { + new + { + title = $"Result 1: {searchText}", + text = "Click to see full details", + tap = new + { + type = "invoke", + value = new + { + itemId = "item-1", + title = $"Full details for Result 1: {searchText}", + description = "This is the expanded content" + } + } + }, + new + { + title = $"Result 2: {searchText}", + text = "Click to see full details", + tap = new + { + type = "invoke", + value = new + { + itemId = "item-2", + title = $"Full details for Result 2: {searchText}", + description = "This is more expanded content" + } + } + } + }; + } + + public static object CreateSelectItemCard(string? itemId, string? title, string? description) + { + return new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = title, size = "large", weight = "bolder" }, + new { type = "TextBlock", text = description, wrap = true }, + new { type = "FactSet", facts = new[] + { + new { title = "Item ID:", value = itemId } + } + } + } + }; + } + + public static object CreateFetchTaskCard(string? commandId) + { + return new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = $"Fetch Task for: {commandId}", size = "large", weight = "bolder" }, + new { type = "Input.Text", id = "title", label = "Title", placeholder = "Enter a title" }, + new { type = "Input.Text", id = "description", label = "Description", placeholder = "Enter a description", isMultiline = true } + }, + actions = new object[] + { + new { type = "Action.Submit", title = "Submit" } + } + }; + } + + public static object CreateEditFormCard(string? previewTitle, string? previewDescription) + { + return new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = "Edit Your Card", size = "large", weight = "bolder" }, + new { type = "Input.Text", id = "title", label = "Title", placeholder = "Enter a title", value = previewTitle }, + new { type = "Input.Text", id = "description", label = "Description", placeholder = "Enter a description", isMultiline = true, value = previewDescription } + }, + actions = new object[] { new { type = "Action.Submit", title = "Submit" } } + }; + } + + public static object CreateSubmitActionCard(string? title, string? description) + { + return new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", text = title ?? "Untitled", size = "large", weight = "bolder", color = "accent" }, + new { type = "TextBlock", text = description ?? "No description", wrap = true } + } + }; + } + + public static object CreateLinkUnfurlCard(string? url) + { + return new { title = $"Link Unfurled: {url}" }; + } +} diff --git a/core/samples/MessageExtensionBot/MessageExtensionBot.csproj b/core/samples/MessageExtensionBot/MessageExtensionBot.csproj new file mode 100644 index 000000000..edd383c6e --- /dev/null +++ b/core/samples/MessageExtensionBot/MessageExtensionBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/MessageExtensionBot/Program.cs b/core/samples/MessageExtensionBot/Program.cs new file mode 100644 index 000000000..21bcdf7d3 --- /dev/null +++ b/core/samples/MessageExtensionBot/Program.cs @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using MessageExtensionBot; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers.MessageExtension; +using Microsoft.Teams.Apps.Handlers.TaskModules; +using Microsoft.Teams.Apps.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication bot = webApp.UseTeamsBotApplication(); + +// ==================== MESSAGE EXTENSION QUERY ==================== +bot.OnQuery(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnQuery"); + + MessageExtensionQuery? query = context.Activity.Value; + string commandId = query?.CommandId ?? "unknown"; + string searchText = query?.Parameters + .FirstOrDefault(p => !p.Name.Equals("initialRun"))? + .Value ?? "default"; + + if (searchText.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Message) + .WithText("💡 Search for any keyword to see results.") + .Build(); + } + + // Create results with tap actions to trigger OnSelectItem + object[] cards = Cards.CreateQueryResultCards(searchText); + TeamsAttachment[] attachments = [.. cards.Select(card => TeamsAttachment.CreateBuilder().WithContent(card) + .WithContentType(AttachmentContentType.ThumbnailCard).Build())]; + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachments) + .Build(); +}); + +// ==================== MESSAGE EXTENSION SELECT ITEM ==================== +bot.OnSelectItem(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnSelectItem"); + + JsonElement selectedItem = context.Activity.Value; + JsonElement? itemData = selectedItem; + string? itemId = itemData.Value.TryGetProperty("itemId", out JsonElement id) ? id.GetString() : "unknown"; + string? title = itemData.Value.TryGetProperty("title", out JsonElement t) ? t.GetString() : "Selected Item"; + string? description = itemData.Value.TryGetProperty("description", out JsonElement d) ? d.GetString() : "No description"; + + object card = Cards.CreateSelectItemCard(itemId, title, description); + TeamsAttachment attachment = TeamsAttachment.CreateBuilder().WithAdaptiveCard(card).Build(); + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachment) + .Build(); +}); + +// ==================== MESSAGE EXTENSION FETCH TASK ==================== +bot.OnFetchTask(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnFetchTask"); + + MessageExtensionAction? action = context.Activity.Value; + + object fetchTaskCard = Cards.CreateFetchTaskCard(action?.CommandId ?? "unknown"); + TeamsAttachment fetchTaskCardResponse = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(fetchTaskCard).Build(); + return MessageExtensionActionResponse.CreateBuilder() + .WithTask(TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Task Module") + .WithCard(fetchTaskCardResponse)) + .Build(); +}); + +// Helper: Extract title and description from preview card +static (string?, string?) GetDataFromPreview(TeamsActivity? preview) +{ + if (preview is not MessageActivity msg || msg.Attachments == null) return (null, null); + + JsonElement cardData = JsonSerializer.Deserialize( + JsonSerializer.Serialize(msg.Attachments[0].Content)); + + if (!cardData.TryGetProperty("body", out JsonElement body) || body.ValueKind != JsonValueKind.Array) + return (null, null); + + string? title = body.GetArrayLength() > 0 && body[0].TryGetProperty("text", out JsonElement t) ? t.GetString() : null; + string? description = body.GetArrayLength() > 1 && body[1].TryGetProperty("text", out JsonElement d) ? d.GetString() : null; + + return (title, description); +} + + +// ==================== MESSAGE EXTENSION SUBMIT ACTION ==================== +bot.OnSubmitAction(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnSubmitAction"); + + MessageExtensionAction? action = context.Activity.Value; + + // Handle "edit" - user clicked edit on the preview, show the form again + if (action?.BotMessagePreviewAction == "edit") + { + Console.WriteLine("Handling EDIT action - returning to form"); + (string? previewTitle, string? previewDescription) = GetDataFromPreview(action.BotActivityPreview?.FirstOrDefault()); + + object editFormCard = Cards.CreateEditFormCard(previewTitle, previewDescription); + TeamsAttachment editFormCardResponse = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(editFormCard).Build(); + return MessageExtensionActionResponse.CreateBuilder() + .WithTask(TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Edit Card") + .WithCard(editFormCardResponse)) + .Build(); + } + + // Handle "send" - user clicked send on the preview, finalize the card + //TODO : when I start from the compose box or message, i get an error at this point but seems to be a teams issue ( no activity is sent on clicking send) + if (action?.BotMessagePreviewAction == "send") + { + Console.WriteLine("Handling SEND action - finalizing card"); + (string? previewTitle, string? previewDescription) = GetDataFromPreview(action.BotActivityPreview?.FirstOrDefault()); + + object card = Cards.CreateSubmitActionCard(previewTitle, previewDescription); + TeamsAttachment attachment2 = TeamsAttachment.CreateBuilder().WithAdaptiveCard(card).Build(); + + return MessageExtensionActionResponse.CreateBuilder() + .WithComposeExtension(MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachment2)) + .Build(); + } + + + JsonElement? data = action?.Data as JsonElement?; + string? title = data != null && data.Value.TryGetProperty("title", out JsonElement t) ? t.GetString() : "Untitled"; + string? description = data != null && data.Value.TryGetProperty("description", out JsonElement d) ? d.GetString() : "No description"; + + object previewCard = Cards.CreateSubmitActionCard(title, description); + TeamsAttachment attachment = TeamsAttachment.CreateBuilder().WithAdaptiveCard(previewCard).Build(); + + return MessageExtensionActionResponse.CreateBuilder() + .WithComposeExtension(MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.BotMessagePreview) + .WithActivityPreview(new MessageActivity([attachment])) + ) + .Build(); +}); + +// ==================== MESSAGE EXTENSION QUERY LINK ==================== +bot.OnQueryLink(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnQueryLink"); + + MessageExtensionQueryLink? queryLink = context.Activity.Value; + + object card = Cards.CreateLinkUnfurlCard(queryLink?.Url?.ToString()); + TeamsAttachment attachment = TeamsAttachment.CreateBuilder() + .WithContent(card).WithContentType(AttachmentContentType.ThumbnailCard).Build(); + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachment) + .Build(); +}); + +// ==================== MESSAGE EXTENSION ANON QUERY LINK ==================== +//TODO : difficult to test, app must be published to catalog +bot.OnAnonQueryLink(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnAnonQueryLink"); + + MessageExtensionQueryLink? anonQueryLink = context.Activity.Value; + if (anonQueryLink != null) + { + Console.WriteLine($" URL: '{anonQueryLink.Url}'"); + } + + object card = Cards.CreateLinkUnfurlCard(anonQueryLink?.Url?.ToString()); + TeamsAttachment attachment = TeamsAttachment.CreateBuilder() + .WithContent(card).WithContentType(AttachmentContentType.ThumbnailCard).Build(); + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Result) + .WithAttachmentLayout(TeamsAttachmentLayout.List) + .WithAttachments(attachment) + .Build(); +}); + + +// ==================== MESSAGE EXTENSION QUERY SETTING URL ==================== +bot.OnQuerySettingUrl(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnQuerySettingUrl"); + + MessageExtensionQuery? query = context.Activity.Value; + + var action = new + { + Type = "openUrl", + Value = "https://www.microsoft.com" + }; + + return MessageExtensionResponse.CreateBuilder() + .WithType(MessageExtensionResponseType.Config) + .WithSuggestedActions([action]) + .Build(); +}); + + +//TODO : this is deprecated ? +// ==================== MESSAGE EXTENSION CARD BUTTON CLICKED ==================== +//bot.OnCardButtonClicked(async (context, cancellationToken) => +//{ +// Console.WriteLine("✓ OnCardButtonClicked"); +// Console.WriteLine($" Activity Type: {context.Activity.GetType().Name}"); +// +// return new CoreInvokeResponse(200); +//}); + +//TODO : only able to get OnQuerySettingUrl activity, how do we get onSetting or OnConfigFetch +/* +// ==================== MESSAGE EXTENSION SETTING ==================== +bot.OnSetting(async (context, cancellationToken) => +{ + Console.WriteLine("✓ OnSetting"); + + var query = context.Activity.Value; + if (query != null) + { + Console.WriteLine($" Command ID: '{query.CommandId}'"); + } + + var action = new MessagingExtensionAction + { + Type = "openUrl", + Value = "https://microsoft.com", + Title = "Configure Settings" + }; + + var response = MessagingExtensionResponse.CreateBuilder() + .WithType(MessagingExtensionResponseType.Config) + .WithSuggestedActions(action) + .Build(); + + return new CoreInvokeResponse(200, response); +}); +*/ + +webApp.Run(); diff --git a/core/samples/MessageExtensionBot/README.md b/core/samples/MessageExtensionBot/README.md new file mode 100644 index 000000000..f8062b00c --- /dev/null +++ b/core/samples/MessageExtensionBot/README.md @@ -0,0 +1,55 @@ +# MessageExtensionBot Testing Guide + +A sample bot demonstrating Teams message extension handlers. + +## Setup + +1. Configure bot credentials in `appsettings.json` or environment variables +2. Run the bot: `dotnet run` +3. Upload `manifest.json` to Teams + +## Testing Handlers + +### OnQuery (Search) +**Manifest:** `composeExtensions.commands` with `type: "query"` + +1. Open message compose box +2. Select the message extension +3. Type a search term +4. Verify results display in list format +5. Type "help" to test message response + +### OnSelectItem +**Manifest:** No specific requirement (works with OnQuery results) + +1. After running a search (OnQuery) +2. Click on any search result +3. Verify adaptive card preview appears + +### OnFetchTask (Action - Task Module) +**Manifest:** `composeExtensions.commands` with `type: "action"` and `fetchTask: true` + +1. Click the message extension action button (createAction) +2. Verify task module opens with input form + +### OnSubmitAction (Action Submit) +**Manifest:** No specific requirement (works with OnFetchTask) + +1. Fill form in task module +2. Click submit +3. Verify preview card appears with Edit/Send buttons +4. Click Edit - verify form reopens with values +5. Click Send - verify final card posts to conversation -- Currently this only works when we start from commandbox. + +### OnQueryLink (Link Unfurling) +**Manifest:** `composeExtensions.messageHandlers` with `type: "link"` and `domains` + +1. Paste a URL in compose box that matches the unfurl domain in manifest (*.example.com) +2. Verify card unfurls automatically + +### OnQuerySettingUrl (Settings) +**Manifest:** `composeExtensions.canUpdateConfiguration: true` + +1. Right-click message extension icon +2. Select Settings +3. Verify settings URL opens (microsoft.com) diff --git a/core/samples/MessageExtensionBot/appsettings.json b/core/samples/MessageExtensionBot/appsettings.json new file mode 100644 index 000000000..5febf4fe3 --- /dev/null +++ b/core/samples/MessageExtensionBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/MessageExtensionBot/manifest.json b/core/samples/MessageExtensionBot/manifest.json new file mode 100644 index 000000000..d6c36e982 --- /dev/null +++ b/core/samples/MessageExtensionBot/manifest.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.24/MicrosoftTeams.schema.json", + "version": "1.0.0", + "manifestVersion": "1.24", + "id": "YOUR_BOT_ID", + "name": { + "short": "YOUR_BOT_NAME", + "full": "YOUR_BOT_NAME" + }, + "developer": { + "name": "Microsoft", + "mpnId": "", + "websiteUrl": "https://microsoft.com", + "privacyUrl": "https://privacy.microsoft.com/privacystatement", + "termsOfUseUrl": "https://www.microsoft.com/legal/terms-of-use" + }, + "description": { + "short": "YOUR_BOT_NAME", + "full": "YOUR_BOT_NAME" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "staticTabs": [ + { + "entityId": "conversations", + "scopes": [ + "personal" + ] + }, + { + "entityId": "about", + "scopes": [ + "personal" + ] + } + ], + "bots": [ + { + "botId": "YOUR_BOT_ID", + "scopes": [ + "personal", + "team", + "groupChat" + ], + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "supportsFiles": false + } + ], + "composeExtensions": [ + { + "botId": "YOUR_BOT_ID", + "commands": [ + { + "id": "searchQuery", + "type": "query", + "title": "searchQuery", + "description": "Enter search text", + "initialRun": true, + "fetchTask": false, + "context": [ + "commandBox", + "compose", + "message" + ], + "parameters": [ + { + "name": "searchText", + "title": "searchText", + "description": "Enter search text", + "inputType": "text" + } + ] + }, + { + "id": "createAction", + "type": "action", + "title": "createAction", + "description": "Create a new item", + "initialRun": true, + "fetchTask": true, + "context": [ + "commandBox", + "compose", + "message" + ], + "parameters": [ + { + "name": "createAction", + "title": "createAction", + "description": "Create a new item", + "inputType": "text" + } + ] + } + ], + "canUpdateConfiguration": true, + "messageHandlers": [ + { + "type": "link", + "value": { + "domains": [ + "*.example.com", + "*.microsoft.com" + ], + "supportsAnonymizedPayloads": true + } + } + ] + } + ], + "validDomains": [ + "*.microsoft.com" + ], + "webApplicationInfo": { + "id": "YOUR_BOT_ID", + "resource": "https://graph.microsoft.com" + } +} diff --git a/core/samples/OAuthFlowBot/OAuthFlowBot.csproj b/core/samples/OAuthFlowBot/OAuthFlowBot.csproj new file mode 100644 index 000000000..0f379b438 --- /dev/null +++ b/core/samples/OAuthFlowBot/OAuthFlowBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/OAuthFlowBot/Program.cs b/core/samples/OAuthFlowBot/Program.cs new file mode 100644 index 000000000..ac1601438 --- /dev/null +++ b/core/samples/OAuthFlowBot/Program.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates how to use OAuthFlow with two OAuth connections: +// - GraphConnection: Microsoft Graph (Azure AD v2) for user profile and calendar +// - GitHubConnection: GitHub for repositories +// +// Azure Bot resource must have two OAuth connection settings configured: +// | Connection name | Provider | Scopes | +// |-------------------|--------------|---------------------------| +// | GraphConnection | Azure AD v2 | User.Read Calendars.Read | +// | GitHubConnection | GitHub | repo read:user | + +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.OAuth; +using Microsoft.Teams.Apps.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); + +// Configure OAuth flows at the DI level -- card text is set once here +webAppBuilder.Services.AddTeamsBotApplication(options => +{ + options.AddOAuthFlow("sso", o => + { + o.OAuthCardText = "Sign in to your Microsoft account"; + o.SignInButtonText = "Sign In to Graph"; + }); + options.AddOAuthFlow("gh", o => + { + o.OAuthCardText = "Sign in to your GitHub account"; + o.SignInButtonText = "Sign In to GitHub"; + }); +}); + +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication bot = webApp.UseTeamsBotApplication(); + +// ==================== OAUTH FLOW SETUP ==================== + +// Get the pre-registered flows and attach callbacks +OAuthFlow graphAuth = bot.GetOAuthFlow("sso"); +OAuthFlow githubAuth = bot.GetOAuthFlow("gh"); + +graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync($"User {context.Activity.From?.Name} connected to Microsoft Graph ({tokenResponse.ConnectionName})!", ct); +}); + +graphAuth.OnSignInFailure(async (context, failure, ct) => +{ + await context.SendActivityAsync($"User {context.Activity.From?.Name} failed to connect to Microsoft Graph. {failure?.Message}", ct); +}); + +githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync($"User {context.Activity.From?.Name} connected to GitHub ({tokenResponse.ConnectionName})!", ct); +}); + +githubAuth.OnSignInFailure(async (context, failure, ct) => +{ + await context.SendActivityAsync($"User {context.Activity.From?.Name} failed to connect to GitHub. {failure?.Message}", ct); +}); + +// ==================== MESSAGE HANDLERS ==================== + +bot.OnMessage("(?i)^help$", async (context, ct) => +{ + string helpText = """ + **OAuthFlow Bot** - Multi-connection OAuth sample + + Commands: + - `login` - Sign in to all connections + - `login graph` - Sign in to Microsoft Graph + - `login github` - Sign in to GitHub + - `status` - Show OAuth connection status + - `my ad user` - Get your Azure AD user (requires Graph) + - `my gh user` - Get your GitHub user (requires GitHub) + - `logout` - Sign out from all connections + - `logout graph` - Sign out from Graph only + - `logout github` - Sign out from GitHub only + - `help` - Show this message + """; + + await context.SendActivityAsync( + new MessageActivity(helpText) { TextFormat = TextFormats.Markdown }, ct); +}); + +bot.OnMessage("(?i)^login$", async (context, ct) => +{ + string? tokenGitHub = await githubAuth.SignInAsync(context, ct); + string? tokenGraph = await graphAuth.SignInAsync(context, ct); + if (tokenGraph is not null) + { + await context.SendActivityAsync("Already signed in to Graph.", ct); + } + + if (tokenGitHub is not null) + { + await context.SendActivityAsync("Already signed in to GitHub.", ct); + } + +}); + +bot.OnMessage("(?i)^login graph$", async (context, ct) => +{ + string? tokenGraph = await graphAuth.SignInAsync(context, ct); + if (tokenGraph is not null) + { + await context.SendActivityAsync("Already signed in to Graph.", ct); + } + // else: OAuthCard sent, SSO in progress +}); + +bot.OnMessage("(?i)^login github$", async (context, ct) => +{ + string? tokenGitHub = await githubAuth.SignInAsync(context, ct); + if (tokenGitHub is not null) + { + await context.SendActivityAsync("Already signed in to GitHub.", ct); + } +}); + +bot.OnMessage("(?i)^status$", async (context, ct) => +{ + // GetConnectionStatusAsync returns ALL connections -- no names needed + var statuses = await graphAuth.GetConnectionStatusAsync(context, ct); + var lines = statuses.Select(s => + $"- **{s.ConnectionName}** ({s.ServiceProviderDisplayName}): " + + $"{(s.HasToken == true ? "✅ connected" : "❌ not connected")}"); + + await context.SendActivityAsync( + new MessageActivity($"OAuth connections for {context.Activity.From?.Name} :\n" + string.Join("\n", lines)) + { + TextFormat = TextFormats.Markdown + }, ct); +}); + +bot.OnMessage("(?i)^my ad user", async (context, ct) => +{ + string? token = await graphAuth.SignInAsync(context, ct); + if (token is null) return; + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + + try + { + string response = await http.GetStringAsync( + "https://graph.microsoft.com/v1.0/me", ct); + await context.SendActivityAsync($"Your Azure AD user :\n```json\n{response}\n```", ct); + } + catch (HttpRequestException ex) + { + await context.SendActivityAsync($"Failed to fetch Azure AD user: {ex.Message}", ct); + } +}); + +bot.OnMessage("(?i)^my gh user$", async (context, ct) => +{ + string? token = await githubAuth.SignInAsync(context, ct); + if (token is null) return; + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + http.DefaultRequestHeaders.UserAgent.ParseAdd("TeamsBot/1.0"); + + try + { + string response = await http.GetStringAsync( + "https://api.github.com/user", ct); + await context.SendActivityAsync($"Your GitHub user :\n```json\n{response}\n```", ct); + } + catch (HttpRequestException ex) + { + await context.SendActivityAsync($"Failed to fetch GitHub user: {ex.Message}", ct); + } +}); + +bot.OnMessage("(?i)^logout$", async (context, ct) => +{ + await graphAuth.SignOutAsync(context, ct); + await githubAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from all services.", ct); +}); + +bot.OnMessage("(?i)^logout graph$", async (context, ct) => +{ + await graphAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from Graph.", ct); +}); + +bot.OnMessage("(?i)^logout github$", async (context, ct) => +{ + await githubAuth.SignOutAsync(context, ct); + await context.SendActivityAsync("Signed out from GitHub.", ct); +}); + +// ==================== INSTALL HANDLER ==================== + +bot.OnInstall(async (context, ct) => +{ + await context.SendActivityAsync( + new MessageActivity("Welcome to the **OAuthFlow Bot**! Type `help` to see available commands.") + { + TextFormat = TextFormats.Markdown + }, ct); +}); + +webApp.Run(); diff --git a/core/samples/OAuthFlowBot/appsettings.json b/core/samples/OAuthFlowBot/appsettings.json new file mode 100644 index 000000000..5febf4fe3 --- /dev/null +++ b/core/samples/OAuthFlowBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/PABot/AdapterWithErrorHandler.cs b/core/samples/PABot/AdapterWithErrorHandler.cs new file mode 100644 index 000000000..0746f8614 --- /dev/null +++ b/core/samples/PABot/AdapterWithErrorHandler.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Builder.TraceExtensions; +using Microsoft.Teams.Apps.BotBuilder; +using Microsoft.Teams.Core; + +namespace PABot +{ + public class AdapterWithErrorHandler : TeamsBotFrameworkHttpAdapter + { + public AdapterWithErrorHandler( + BotApplication teamsBotApp, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration, + ILogger logger, + IStorage storage, + ConversationState conversationState + ) + : base( + teamsBotApp, + httpContextAccessor, + logger) + { + base.Use(new TeamsSSOTokenExchangeMiddleware(storage, configuration["ConnectionName"] ?? "graph")); + + OnTurnError = async (turnContext, exception) => + { + // Log any leaked exception from the application. + // NOTE: In production environment, you should consider logging this to + // Azure Application Insights. Visit https://aka.ms/bottelemetry to see how + // to add telemetry capture to your bot. + logger.LogError(exception, $"[OnTurnError] unhandled error : {exception.Message}"); + + // Uncomment below commented line for local debugging.. + // await turnContext.SendActivityAsync($"Sorry, it looks like something went wrong. Exception Caught: {exception.Message}"); + + if (conversationState != null) + { + try + { + // Delete the conversationState for the current conversation to prevent the + // bot from getting stuck in a error-loop caused by being in a bad state. + // ConversationState should be thought of as similar to "cookie-state" in a Web pages. + await conversationState.DeleteAsync(turnContext); + } + catch (Exception e) + { + logger.LogError(e, $"Exception caught on attempting to Delete ConversationState : {e.Message}"); + } + } + + // Send a trace activity, which will be displayed in the Bot Framework Emulator + await turnContext.TraceActivityAsync( + "OnTurnError Trace", + exception.Message, + "https://www.botframework.com/schemas/error", + "TurnError"); + }; + } + } +} diff --git a/core/samples/PABot/Bots/DialogBot.cs b/core/samples/PABot/Bots/DialogBot.cs new file mode 100644 index 000000000..02004832b --- /dev/null +++ b/core/samples/PABot/Bots/DialogBot.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Schema; + +namespace PABot.Bots +{ + /// + /// This IBot implementation can run any type of Dialog. The use of type parameterization allows multiple different bots + /// to be run at different endpoints within the same project. This can be achieved by defining distinct Controller types + /// each with dependency on distinct IBot types, this way ASP Dependency Injection can glue everything together without ambiguity. + /// The ConversationState is used by the Dialog system. The UserState isn't, however, it might have been used in a Dialog implementation, + /// and the requirement is that all BotState objects are saved at the end of a turn. + /// + /// The type of the dialog. + public class DialogBot : TeamsActivityHandler where T : Dialog + { + protected readonly BotState _conversationState; + protected readonly Dialog _dialog; + protected readonly ILogger _logger; + protected readonly BotState _userState; + + /// + /// Initializes a new instance of the class. + /// + /// The conversation state. + /// The user state. + /// The dialog. + /// The logger. + public DialogBot(ConversationState conversationState, UserState userState, T dialog, ILogger> logger) + { + _conversationState = conversationState; + _userState = userState; + _dialog = dialog; + _logger = logger; + } + + /// + /// Handles an incoming activity. + /// + /// Context object containing information cached for a single turn of conversation with a user. + /// Propagates notification that operations should be canceled. + /// A task that represents the work queued to execute. + /// + /// Reference link: https://docs.microsoft.com/en-us/dotnet/api/microsoft.bot.builder.activityhandler.onturnasync?view=botbuilder-dotnet-stable. + /// + public override async Task OnTurnAsync( + ITurnContext turnContext, + CancellationToken cancellationToken = default(CancellationToken)) + { + await base.OnTurnAsync(turnContext, cancellationToken); + + // Save any state changes that might have occurred during the turn. + await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + await _userState.SaveChangesAsync(turnContext, false, cancellationToken); + } + + /// + /// Handles when a message is addressed to the bot. + /// + /// Context object containing information cached for a single turn of conversation with a user. + /// Propagates notification that operations should be canceled. + /// A Task resolving to either a login card or the adaptive card of the Reddit post. + /// + /// For more information on bot messaging in Teams, see the documentation + /// https://docs.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-basics?tabs=dotnet#receive-a-message. + /// + protected override async Task OnMessageActivityAsync( + ITurnContext turnContext, + CancellationToken cancellationToken) + { + _logger.LogInformation("Running dialog with Message Activity."); + + await _dialog.RunAsync(turnContext, _conversationState.CreateProperty(nameof(DialogState)), cancellationToken); + } + } +} diff --git a/core/samples/PABot/Bots/EchoBot.cs b/core/samples/PABot/Bots/EchoBot.cs new file mode 100644 index 000000000..e17743c66 --- /dev/null +++ b/core/samples/PABot/Bots/EchoBot.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; + +namespace PABot.Bots +{ + public class EchoBot : ActivityHandler + { + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text($"Echo from TurnContext.SendActivityAsync: {turnContext.Activity.Text}"), cancellationToken); + + IConnectorClient connectorClient = turnContext.TurnState.Get(); + Activity activity = MessageFactory.Text($"Echo from IConversations.SendToConversationAsync: {turnContext.Activity.Text}"); + activity.Conversation = new ConversationAccount { Id = turnContext.Activity.Conversation.Id }; + await connectorClient.Conversations.SendToConversationAsync(activity); + } + } +} diff --git a/core/samples/PABot/Bots/SsoBot.cs b/core/samples/PABot/Bots/SsoBot.cs new file mode 100644 index 000000000..f03074b6b --- /dev/null +++ b/core/samples/PABot/Bots/SsoBot.cs @@ -0,0 +1,394 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Newtonsoft.Json.Linq; +using System.Net; + +namespace PABot.Bots +{ + /// + /// OAuth SSO Bot - Demonstrates OAuth Single Sign-On flow with token exchange. + /// + /// Flow: + /// 1. User sends any message + /// 2. Bot checks if user has a token + /// 3. If no token, sends OAuth SSO card with TokenExchangeResource + /// 4. Client attempts SSO token exchange by sending invoke activity + /// 5. Bot handles token exchange and responds with success/failure + /// 6. If token exchange fails, user clicks sign-in button for manual auth + /// + public class SsoBot(ILogger logger, IConfiguration configuration) : ActivityHandler + { + private readonly IConfiguration _configuration = configuration; + private const string ConnectionName = "graph-sso"; // From launchSettings.json + + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + // Special test scenario to reproduce NullReferenceException bug + if (turnContext.Activity.Text?.Contains("test oauth card", StringComparison.OrdinalIgnoreCase) == true) + { + await TestOAuthCardSendScenario(turnContext, cancellationToken); + return; + } + + UserTokenClient tokenClient = turnContext.TurnState.Get(); + + // Try to get existing token + TokenResponse token = await tokenClient.GetUserTokenAsync( + turnContext.Activity.From.Id, + ConnectionName, + turnContext.Activity.ChannelId, + null, + cancellationToken); + + if (token != null && !string.IsNullOrEmpty(token.Token)) + { + // User is authenticated - show token info + logger.LogInformation("User has valid token for connection '{ConnectionName}'", ConnectionName); + await turnContext.SendActivityAsync( + MessageFactory.Text($"✅ You are signed in!\n\nToken (first 20 chars): {token.Token[..Math.Min(20, token.Token.Length)]}...\n\nYou said: {turnContext.Activity.Text}"), + cancellationToken); + } + else + { + // No token - send OAuth SSO card + logger.LogInformation("No token found, sending OAuth SSO card"); + await SendOAuthCardAsync(turnContext, cancellationToken); + } + } + + protected override async Task OnInvokeActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + logger.LogInformation("Received invoke activity: {Name}", turnContext.Activity.Name); + + // Handle token exchange invoke (SSO) + if (turnContext.Activity.Name == SignInConstants.TokenExchangeOperationName) + { + return await OnTokenExchangeInvokeAsync(turnContext, cancellationToken); + } + + // Handle signin verification invoke (manual sign-in fallback) + if (turnContext.Activity.Name == SignInConstants.VerifyStateOperationName) + { + return await OnVerifyStateInvokeAsync(turnContext, cancellationToken); + } + + // Let base class handle other invokes (like Teams-specific invokes) + return await base.OnInvokeActivityAsync(turnContext, cancellationToken); + } + + /// + /// Sends an OAuth SSO card with TokenExchangeResource to enable SSO. + /// + private async Task SendOAuthCardAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + UserTokenClient tokenClient = turnContext.TurnState.Get(); + + // Get sign-in resource from token service (includes TokenExchangeResource for SSO) + SignInResource signInResource = await tokenClient.GetSignInResourceAsync( + ConnectionName, + (Activity)turnContext.Activity, + string.Empty, + cancellationToken); + + logger.LogInformation("Got sign-in resource. SignInLink: {SignInLink}", signInResource.SignInLink); + logger.LogInformation("TokenExchangeResource.Id: {Id}, Uri: {Uri}", + signInResource.TokenExchangeResource?.Id, + signInResource.TokenExchangeResource?.Uri); + + // Create OAuth SSO card + var oAuthCard = new OAuthCard + { + Text = "Please sign in to continue", + ConnectionName = ConnectionName, + TokenExchangeResource = signInResource.TokenExchangeResource, + TokenPostResource = signInResource.TokenPostResource, + Buttons = new[] + { + new CardAction + { + Title = "Sign In", + Text = "Sign in", + Type = ActionTypes.Signin, + Value = signInResource.SignInLink + } + } + }; + + var reply = MessageFactory.Attachment(oAuthCard.ToAttachment()); + await turnContext.SendActivityAsync(reply, cancellationToken); + } + + /// + /// Handles token exchange invoke for SSO. + /// Client sends this invoke with a token it obtained, and the bot exchanges it for a token for the configured connection. + /// + private async Task OnTokenExchangeInvokeAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + logger.LogInformation("Processing token exchange invoke"); + + // Parse token exchange request from invoke value + TokenExchangeInvokeRequest? tokenExchangeRequest = (turnContext.Activity.Value as JObject)?.ToObject(); + + if (tokenExchangeRequest == null) + { + logger.LogWarning("Token exchange request is null"); + return CreateInvokeResponse( + HttpStatusCode.BadRequest, + new TokenExchangeInvokeResponse + { + Id = null, + ConnectionName = ConnectionName, + FailureDetail = "The bot received an InvokeActivity that is missing a TokenExchangeInvokeRequest value." + }); + } + + logger.LogInformation("Token exchange request - Id: {Id}, ConnectionName: {ConnectionName}", + tokenExchangeRequest.Id, tokenExchangeRequest.ConnectionName); + + // Validate connection name matches + if (tokenExchangeRequest.ConnectionName != ConnectionName) + { + logger.LogWarning("Connection name mismatch. Expected: {Expected}, Got: {Got}", + ConnectionName, tokenExchangeRequest.ConnectionName); + return CreateInvokeResponse( + HttpStatusCode.BadRequest, + new TokenExchangeInvokeResponse + { + Id = tokenExchangeRequest.Id, + ConnectionName = ConnectionName, + FailureDetail = "The bot received a TokenExchangeInvokeRequest with a ConnectionName that does not match." + }); + } + + UserTokenClient tokenClient = turnContext.TurnState.Get(); + + // Attempt token exchange + TokenResponse? tokenExchangeResponse = null; + try + { + tokenExchangeResponse = await tokenClient.ExchangeTokenAsync( + turnContext.Activity.From.Id, + ConnectionName, + turnContext.Activity.ChannelId, + new TokenExchangeRequest { Token = tokenExchangeRequest.Token }, + cancellationToken); + + logger.LogInformation("Token exchange result: {Success}", + tokenExchangeResponse != null && !string.IsNullOrEmpty(tokenExchangeResponse.Token) ? "Success" : "Failed"); + } + catch (Exception ex) + { + logger.LogError(ex, "Token exchange failed with exception"); + // tokenExchangeResponse stays null + } + + // Check if token exchange succeeded + if (tokenExchangeResponse == null || string.IsNullOrEmpty(tokenExchangeResponse.Token)) + { + logger.LogWarning("Token exchange failed - no token received"); + return CreateInvokeResponse( + HttpStatusCode.PreconditionFailed, + new TokenExchangeInvokeResponse + { + Id = tokenExchangeRequest.Id, + ConnectionName = ConnectionName, + FailureDetail = "Token exchange failed. The bot was unable to exchange the token." + }); + } + + // Success! + logger.LogInformation("✅ Token exchange successful!"); + return CreateInvokeResponse( + HttpStatusCode.OK, + new TokenExchangeInvokeResponse + { + Id = tokenExchangeRequest.Id, + ConnectionName = ConnectionName + }); + } + + /// + /// Handles signin verification invoke for manual sign-in fallback. + /// When SSO token exchange fails, user clicks sign-in button and completes auth in browser. + /// Teams then sends this invoke with a "magic code" that the bot must use to get the token. + /// + private async Task OnVerifyStateInvokeAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + logger.LogInformation("Processing signin/verifyState invoke (manual sign-in fallback)"); + + // Extract magic code from invoke value + var magicCodeObject = turnContext.Activity.Value as JObject; + var magicCode = magicCodeObject?.GetValue("state", StringComparison.Ordinal)?.ToString(); + + if (string.IsNullOrEmpty(magicCode)) + { + logger.LogWarning("Magic code is missing from signin/verifyState invoke"); + return CreateInvokeResponse(HttpStatusCode.BadRequest, null); + } + + logger.LogInformation("Magic code received: {MagicCode}", magicCode[..Math.Min(10, magicCode.Length)] + "..."); + + UserTokenClient tokenClient = turnContext.TurnState.Get(); + + // Getting the token follows a different flow in Teams. At the signin completion, Teams + // will send the bot an "invoke" activity that contains a "magic" code. This code MUST + // then be used to try fetching the token from Botframework service within some time + // period. We try here. If it succeeds, we return 200 with an empty body. If it fails + // with a retriable error, we return 500. Teams will re-send another invoke in this case. + // If it fails with a non-retriable error, we return 404. Teams will not retry in that case. + try + { + TokenResponse? token = await tokenClient.GetUserTokenAsync( + turnContext.Activity.From.Id, + ConnectionName, + turnContext.Activity.ChannelId, + magicCode, + cancellationToken); + + if (token != null && !string.IsNullOrEmpty(token.Token)) + { + logger.LogInformation("✅ Magic code verification successful! User is now signed in."); + + // Success - return 200 with empty body + return CreateInvokeResponse(HttpStatusCode.OK, null); + } + else + { + logger.LogWarning("Magic code verification failed - token is null or empty"); + + // Token is null - return 404, Teams will NOT retry + return CreateInvokeResponse(HttpStatusCode.NotFound, null); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Exception during magic code verification"); + + // Exception occurred - return 500, Teams WILL retry + return CreateInvokeResponse(HttpStatusCode.InternalServerError, null); + } + } + + /// + /// Creates an InvokeResponse with the specified status code and body. + /// + private static InvokeResponse CreateInvokeResponse(HttpStatusCode statusCode, object? body) + { + return new InvokeResponse + { + Status = (int)statusCode, + Body = body + }; + } + + /// + /// Test scenario to reproduce the NullReferenceException issue when sending OAuth SSO cards. + /// This mimics the ProjectAgentBot.SendOAuthCardForSSO scenario. + /// + private async Task TestOAuthCardSendScenario(ITurnContext turnContext, CancellationToken cancellationToken) + { + try + { + await turnContext.SendActivityAsync(MessageFactory.Text("Testing OAuth SSO card send scenario..."), cancellationToken); + + // Get connector client - this uses TeamsBotAdapter under the hood + IConnectorClient connectorClient = turnContext.TurnState.Get(); + + if (connectorClient == null) + { + await turnContext.SendActivityAsync(MessageFactory.Text("❌ ERROR: ConnectorClient is null"), cancellationToken); + return; + } + + // Get connection name from environment (set in launchSettings.json) + string? connectionName = _configuration.GetValue("ConnectionName"); + + if (string.IsNullOrEmpty(connectionName)) + { + await turnContext.SendActivityAsync(MessageFactory.Text("❌ ERROR: ConnectionName not configured in launch profile"), cancellationToken); + return; + } + + logger.LogInformation($"Creating SSO OAuth card with ConnectionName: {connectionName}"); + + // Get UserTokenClient from TurnState (like the existing code does on line 29) + UserTokenClient userTokenClient = turnContext.TurnState.Get(); + + // Get sign-in resource from token service + SignInResource signInResource = await userTokenClient.GetSignInResourceAsync( + connectionName, + (Activity)turnContext.Activity, + string.Empty, + cancellationToken + ).ConfigureAwait(false); + + logger.LogInformation($"Got sign-in resource from token service. SignInLink: {signInResource.SignInLink}"); + logger.LogInformation($"TokenExchangeResource: {signInResource.TokenExchangeResource?.Uri}"); + + // Create proper SSO OAuth card exactly like OAuthPrompt does + OAuthCard oAuthSsoCard = new OAuthCard + { + Text = "Please sign in to continue", + ConnectionName = connectionName, + TokenExchangeResource = signInResource.TokenExchangeResource, + TokenPostResource = signInResource.TokenPostResource, + Buttons = new[] + { + new CardAction + { + Title = "Sign in", + Text = "Please sign in to continue", + Type = ActionTypes.Signin, + Value = signInResource.SignInLink + } + } + }; + + // Create activity using MessageFactory.Attachment - this is what ProjectAgentBot does + IMessageActivity activity = MessageFactory.Attachment(oAuthSsoCard.ToAttachment()); + + ((Microsoft.Bot.Schema.Activity)activity).AttachmentLayout = null; + + // Set properties like ProjectAgentBot does (lines 1255-1256) + activity.Recipient = turnContext.Activity.From; // Send to the user who messaged us + activity.Conversation = turnContext.Activity.Conversation; + + logger.LogInformation("Sending OAuth card via connectorClient.Conversations.SendToConversationAsync"); + logger.LogInformation($"ConversationId: {activity.Conversation.Id}"); + logger.LogInformation($"Recipient: {activity.Recipient.Id}"); + + // This is the call that causes NullReferenceException when APX returns 202 with empty body + ResourceResponse response = await connectorClient.Conversations.SendToConversationAsync( + (Microsoft.Bot.Schema.Activity)activity, + cancellationToken + ); + + // If we get here, the call succeeded + await turnContext.SendActivityAsync(MessageFactory.Text($"✅ SUCCESS! Response ID: {response?.Id ?? "NULL"}"), cancellationToken); + logger.LogInformation($"OAuth card sent successfully. Response ID: {response?.Id}"); + } + catch (NullReferenceException nre) + { + string errorMsg = $"❌ NullReferenceException caught! This is the bug we're investigating.\n" + + $"Message: {nre.Message}\n" + + $"StackTrace: {nre.StackTrace}"; + await turnContext.SendActivityAsync(MessageFactory.Text(errorMsg), cancellationToken); + logger.LogError(nre, "NullReferenceException when sending OAuth card"); + } + catch (Exception ex) + { + string errorMsg = $"❌ Unexpected exception: {ex.GetType().Name}\n" + + $"Message: {ex.Message}\n" + + $"StackTrace: {ex.StackTrace}"; + await turnContext.SendActivityAsync(MessageFactory.Text(errorMsg), cancellationToken); + logger.LogError(ex, "Exception when sending OAuth card"); + } + } + } +} diff --git a/core/samples/PABot/Bots/TeamsBot.cs b/core/samples/PABot/Bots/TeamsBot.cs new file mode 100644 index 000000000..2369ac742 --- /dev/null +++ b/core/samples/PABot/Bots/TeamsBot.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Apps.BotBuilder; + +namespace PABot.Bots +{ + /// + /// This bot is derived from the TeamsActivityHandler class and handles Teams-specific activities. + /// + /// The type of the dialog. + public class TeamsBot : DialogBot where T : Dialog + { + /// + /// Initializes a new instance of the class. + /// + /// The conversation state. + /// The user state. + /// The dialog. + /// The logger. + public TeamsBot(ConversationState conversationState, UserState userState, T dialog, ILogger> logger) + : base(conversationState, userState, dialog, logger) + { + } + + /// + /// Handles the event when members are added to the conversation. + /// + /// The list of members added. + /// The turn context. + /// The cancellation token. + /// A task that represents the work queued to execute. + protected override async Task OnMembersAddedAsync(IList membersAdded, ITurnContext turnContext, CancellationToken cancellationToken) + { + foreach (ChannelAccount member in membersAdded) + { + if (member.Id != turnContext.Activity.Recipient.Id) + { + string welcomeMessage = "Welcome to AuthenticationBot. Type anything to get logged in. Type 'logout' to sign-out.\n" + + "Try these commands:\n" + + "- /help - Show detailed help and bot capabilities\n" + + "- /member-info - Get your member details\n" + + "- /team-info - Get team details (in team context)\n" + + "- /create-conversation - Create 1:1 chat (from group chat)"; + + await turnContext.SendActivityAsync(MessageFactory.Text(welcomeMessage), cancellationToken); + + // Use TeamsApiClient.GetMemberAsync to get detailed member information + try + { + TeamsChannelAccount memberDetails = await TeamsApiClient.GetMemberAsync(turnContext, member.Id, cancellationToken); + _logger.LogInformation("Member added: {Name} ({Email}), AAD Object ID: {AadObjectId}", + memberDetails.Name, memberDetails.Email, memberDetails.AadObjectId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not retrieve member details for {MemberId}", member.Id); + } + } + } + } + + /// + /// Handles the Teams sign-in verification state. + /// + /// The turn context. + /// The cancellation token. + /// A task that represents the work queued to execute. + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + string text = System.Text.RegularExpressions.Regex.Replace( + turnContext.Activity.Text ?? string.Empty, @"[^<]*<\/at>", string.Empty).Trim(); + + if (text.Equals("/help", StringComparison.OrdinalIgnoreCase)) + { + string helpMessage = "**PABot - Personal Assistant Bot**\n\n" + + "I'm a Teams bot that demonstrates authentication and Teams integration capabilities.\n\n" + + "**Authentication Features:**\n" + + "- Dual OAuth authentication (graph and graph-2 connections)\n" + + "- Sequential authentication flow - authenticate both connections one after another\n" + + "- Retrieve and display user profile information from Microsoft Graph\n" + + "- Show user profile photos\n" + + "- Display OAuth tokens for both connections\n\n" + + "**Available Commands:**\n" + + "- **/help** - Show this help message\n" + + "- **/member-info** - Get detailed information about your Teams member account\n" + + " - Displays: Name, ID, AAD Object ID, User Principal Name, Email\n" + + "- **/team-info** - Get details about the current team (only works in team context)\n" + + " - Displays: Team Name, ID, AAD Group ID\n" + + "- **/create-conversation** - Create a 1:1 conversation from a group chat\n" + + " - Only available in group chat contexts\n" + + "- **logout** - Sign out from authenticated connections\n\n" + + "**How to use:**\n" + + "1. Send any message to start the authentication flow\n" + + "2. Authenticate with the first connection (graph)\n" + + "3. Authenticate with the second connection (graph-2)\n" + + "4. View your profile information automatically\n" + + "5. Use commands to explore Teams integration features\n\n" + + "**Technical Features:**\n" + + "- Uses TeamsApiClient for Teams member and team information\n" + + "- Demonstrates Microsoft Graph integration\n" + + "- Shows OAuth connection management\n" + + "- Logs detailed member information when users join"; + + await turnContext.SendActivityAsync(MessageFactory.Text(helpMessage), cancellationToken); + return; + } + + if (text.Equals("/create-conversation", StringComparison.OrdinalIgnoreCase)) + { + if (turnContext.Activity.Conversation.IsGroup != true) + { + await turnContext.SendActivityAsync(MessageFactory.Text("This command can only be used in a group chat."), cancellationToken); + return; + } + + TeamsChannelData channelData = turnContext.Activity.GetChannelData(); + ChannelAccount userChannel = turnContext.Activity.From; + + ConversationParameters conversationParameters = new ConversationParameters + { + IsGroup = false, + Bot = new ChannelAccount { Id = turnContext.Activity.Recipient.Id }, + Members = [userChannel], + TenantId = channelData.Tenant.Id, + }; + + _logger.LogInformation("Creating 1:1 conversation with user {UserId} in tenant {TenantId}", + userChannel.Id, conversationParameters.TenantId); + + IConnectorClient connectorClient = turnContext.TurnState.Get(); + ConversationResourceResponse conv = await connectorClient.Conversations.CreateConversationAsync(conversationParameters, cancellationToken); + + _logger.LogInformation("Created conversation {ConversationId}", conv.Id); + + Activity message = MessageFactory.Text("Hello! I've started a 1:1 conversation with you from the group chat."); + message.ServiceUrl = turnContext.Activity.ServiceUrl; + await connectorClient.Conversations.SendToConversationAsync(conv.Id, message, cancellationToken); + + await turnContext.SendActivityAsync(MessageFactory.Text("Done! Check your personal chat."), cancellationToken); + return; + } + + if (text.Equals("/member-info", StringComparison.OrdinalIgnoreCase)) + { + // Get member details using TeamsApiClient.GetMemberAsync + string userId = turnContext.Activity.From.Id; + TeamsChannelAccount member = await TeamsApiClient.GetMemberAsync(turnContext, userId, cancellationToken); + + string memberInfo = $"Member Details:\n" + + $"- Name: {member.Name}\n" + + $"- ID: {member.Id}\n" + + $"- AAD Object ID: {member.AadObjectId}\n" + + $"- User Principal Name: {member.UserPrincipalName}\n" + + $"- Email: {member.Email}"; + + await turnContext.SendActivityAsync(MessageFactory.Text(memberInfo), cancellationToken); + return; + } + + if (text.Equals("/team-info", StringComparison.OrdinalIgnoreCase)) + { + // Get team details using TeamsApiClient.GetTeamDetailsAsync + TeamInfo? teamInfo = turnContext.Activity.TeamsGetTeamInfo(); + if (teamInfo?.Id == null) + { + await turnContext.SendActivityAsync(MessageFactory.Text("This command can only be used in a team context."), cancellationToken); + return; + } + + TeamDetails teamDetails = await TeamsApiClient.GetTeamDetailsAsync(turnContext, teamInfo.Id, cancellationToken); + + string teamDetailsInfo = $"Team Details:\n" + + $"- Name: {teamDetails.Name}\n" + + $"- ID: {teamDetails.Id}\n" + + $"- AAD Group ID: {teamDetails.AadGroupId}"; + + await turnContext.SendActivityAsync(MessageFactory.Text(teamDetailsInfo), cancellationToken); + return; + } + + await base.OnMessageActivityAsync(turnContext, cancellationToken); + } + + protected override async Task OnTeamsSigninVerifyStateAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + _logger.LogInformation("Running dialog with sign-in/verify state from an Invoke Activity."); + + // The OAuth Prompt needs to see the Invoke Activity in order to complete the login process. + // Run the Dialog with the new Invoke Activity. + await _dialog.RunAsync(turnContext, _conversationState.CreateProperty(nameof(DialogState)), cancellationToken); + } + + /// + /// Handles invoke activities, including signin/failure events. + /// + /// The turn context. + /// The cancellation token. + /// An invoke response. + protected override async Task OnInvokeActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + if (turnContext.Activity.Name == "signin/failure") + { + _logger.LogWarning("Sign-in failure detected. Activity Name: {ActivityName}", turnContext.Activity.Name); + _logger.LogWarning("Sign-in failure details - ConversationId: {ConversationId}, From: {FromId}, Value: {Value}", + turnContext.Activity.Conversation?.Id, + turnContext.Activity.From?.Id, + Newtonsoft.Json.JsonConvert.SerializeObject(turnContext.Activity)); + } + + return await base.OnInvokeActivityAsync(turnContext, cancellationToken); + } + } +} diff --git a/core/samples/PABot/Dialogs/LogoutDialog.cs b/core/samples/PABot/Dialogs/LogoutDialog.cs new file mode 100644 index 000000000..24cb42831 --- /dev/null +++ b/core/samples/PABot/Dialogs/LogoutDialog.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; + +namespace PABot.Dialogs +{ + /// + /// A dialog that handles user logout. + /// + public class LogoutDialog : ComponentDialog + { + /// + /// Initializes a new instance of the class. + /// + /// The dialog ID. + /// The connection name configured in Azure Bot service. + public LogoutDialog(string id, string connectionName) + : base(id) + { + ConnectionName = connectionName; + } + + /// + /// Gets the configured connection name in Azure Bot service. + /// + protected string ConnectionName { get; } + + /// + /// Called when the dialog is started and pushed onto the parent's dialog stack. + /// + /// The inner DialogContext for the current turn of conversation. + /// Initial information to pass to the dialog. + /// Propagates notification that operations should be canceled. + /// A task representing the asynchronous operation. + protected override async Task OnBeginDialogAsync( + DialogContext innerDc, + object options, + CancellationToken cancellationToken = default(CancellationToken)) + { + DialogTurnResult result = await InterruptAsync(innerDc, cancellationToken); + if (result != null) + { + return result; + } + + return await base.OnBeginDialogAsync(innerDc, options, cancellationToken); + } + + /// + /// Called when the dialog is continued, where it is the active dialog and the user replies with a new activity. + /// + /// The inner DialogContext for the current turn of conversation. + /// Propagates notification that operations should be canceled. + /// A task representing the asynchronous operation. + protected override async Task OnContinueDialogAsync( + DialogContext innerDc, + CancellationToken cancellationToken = default(CancellationToken)) + { + DialogTurnResult result = await InterruptAsync(innerDc, cancellationToken); + if (result != null) + { + return result; + } + + return await base.OnContinueDialogAsync(innerDc, cancellationToken); + } + + /// + /// Called when the dialog is interrupted, where it is the active dialog and the user replies with a new activity. + /// + /// The inner DialogContext for the current turn of conversation. + /// Propagates notification that operations should be canceled. + /// A task representing the asynchronous operation. + private async Task InterruptAsync( + DialogContext innerDc, + CancellationToken cancellationToken = default(CancellationToken)) + { + if (innerDc.Context.Activity.Type == ActivityTypes.Message) + { + string text = innerDc.Context.Activity.Text.ToLowerInvariant(); + + // Allow logout anywhere in the command + if (text.Contains("logout")) + { + // The UserTokenClient encapsulates the authentication processes. + UserTokenClient userTokenClient = innerDc.Context.TurnState.Get(); + await userTokenClient.SignOutUserAsync(innerDc.Context.Activity.From.Id, ConnectionName, innerDc.Context.Activity.ChannelId, cancellationToken).ConfigureAwait(false); + + await innerDc.Context.SendActivityAsync(MessageFactory.Text("You have been signed out."), cancellationToken); + return await innerDc.CancelAllDialogsAsync(cancellationToken); + } + } + + return null!; + } + } +} diff --git a/core/samples/PABot/Dialogs/MainDialog.cs b/core/samples/PABot/Dialogs/MainDialog.cs new file mode 100644 index 000000000..94b38915d --- /dev/null +++ b/core/samples/PABot/Dialogs/MainDialog.cs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.Graph.Models; + +namespace PABot.Dialogs +{ + /// + /// Main dialog that handles the authentication and user interactions. + /// + public class MainDialog : LogoutDialog + { + protected readonly ILogger _logger; + private const string FirstOAuthPrompt = "FirstOAuthPrompt"; + private const string SecondOAuthPrompt = "SecondOAuthPrompt"; + private readonly string _firstConnectionName; + private readonly string _secondConnectionName; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration. + /// The logger. + public MainDialog(IConfiguration configuration, ILogger logger) + : base(nameof(MainDialog), configuration["ConnectionName"] ?? "graph") + { + _logger = logger; + + // Load connection names from configuration + _firstConnectionName = configuration["ConnectionName"] ?? "graph"; + _secondConnectionName = configuration["SecondConnectionName"] ?? "graph-2"; + + _logger.LogInformation("Using OAuth connections: {FirstConnection} and {SecondConnection}", + _firstConnectionName, _secondConnectionName); + + // Add first OAuth prompt + AddDialog(new OAuthPrompt( + FirstOAuthPrompt, + new OAuthPromptSettings + { + ConnectionName = _firstConnectionName, + Text = $"Please Sign In to the first connection ({_firstConnectionName})", + Title = $"Sign In - {_firstConnectionName}", + Timeout = 300000, // User has 5 minutes to login (1000 * 60 * 5) + EndOnInvalidMessage = true + })); + + // Add second OAuth prompt + AddDialog(new OAuthPrompt( + SecondOAuthPrompt, + new OAuthPromptSettings + { + ConnectionName = _secondConnectionName, + Text = $"Please Sign In to the second connection ({_secondConnectionName})", + Title = $"Sign In - {_secondConnectionName}", + Timeout = 300000, // User has 5 minutes to login (1000 * 60 * 5) + EndOnInvalidMessage = true + })); + + AddDialog(new ConfirmPrompt(nameof(ConfirmPrompt))); + + AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] + { + PromptFirstConnectionAsync, + LoginFirstConnectionAsync, + PromptSecondConnectionAsync, + LoginSecondConnectionAsync, + DisplayTokenPhase1Async, + DisplayTokenPhase2Async, + })); + + // The initial child Dialog to run. + InitialDialogId = nameof(WaterfallDialog); + } + + /// + /// Prompts the user to sign in to the first connection. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task PromptFirstConnectionAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + _logger.LogInformation("PromptFirstConnectionAsync() called."); + return await stepContext.BeginDialogAsync(FirstOAuthPrompt, null, cancellationToken); + } + + /// + /// Handles the first connection login step. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task LoginFirstConnectionAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + TokenResponse firstTokenResponse = (TokenResponse)stepContext.Result; + if (firstTokenResponse?.Token != null) + { + _logger.LogInformation("First connection ({ConnectionName}) authenticated successfully.", _firstConnectionName); + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"✓ First connection ({_firstConnectionName}) authenticated successfully!"), cancellationToken); + + // Store the first token in step context values + stepContext.Values["FirstToken"] = firstTokenResponse; + + // Continue to next step + return await stepContext.NextAsync(null, cancellationToken); + } + else + { + _logger.LogInformation("First connection ({ConnectionName}) authentication failed.", _firstConnectionName); + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"First connection ({_firstConnectionName}) login was not successful, please try again."), cancellationToken); + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + } + + /// + /// Prompts the user to sign in to the second connection. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task PromptSecondConnectionAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + _logger.LogInformation("PromptSecondConnectionAsync() called."); + return await stepContext.BeginDialogAsync(SecondOAuthPrompt, null, cancellationToken); + } + + /// + /// Handles the second connection login step and displays user information. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task LoginSecondConnectionAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + TokenResponse secondTokenResponse = (TokenResponse)stepContext.Result; + if (secondTokenResponse?.Token != null) + { + _logger.LogInformation("Second connection ({ConnectionName}) authenticated successfully.", _secondConnectionName); + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"✓ Second connection ({_secondConnectionName}) authenticated successfully!"), cancellationToken); + + // Store the second token + stepContext.Values["SecondToken"] = secondTokenResponse; + + // Retrieve the first token + TokenResponse firstTokenResponse = (TokenResponse)stepContext.Values["FirstToken"]; + + try + { + // Use the first token to get user information + SimpleGraphClient client = new(firstTokenResponse.Token); + User me = await client.GetMeAsync(); + string title = !string.IsNullOrEmpty(me.JobTitle) ? me.JobTitle : "Unknown"; + + await stepContext.Context.SendActivityAsync($"You're logged in as {me.DisplayName} ({me.UserPrincipalName}); your job title is: {title}"); + + string photo = await client.GetPhotoAsync(); + + if (!string.IsNullOrEmpty(photo)) + { + CardImage cardImage = new(photo); + ThumbnailCard card = new(images: new List { cardImage }); + IMessageActivity reply = MessageFactory.Attachment(card.ToAttachment()); + + await stepContext.Context.SendActivityAsync(reply, cancellationToken); + } + else + { + await stepContext.Context.SendActivityAsync(MessageFactory.Text("Sorry! User doesn't have a profile picture to display."), cancellationToken); + } + + return await stepContext.PromptAsync( + nameof(ConfirmPrompt), + new PromptOptions { Prompt = MessageFactory.Text("Would you like to view your tokens?") }, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while processing your request."); + } + } + else + { + _logger.LogInformation("Second connection ({ConnectionName}) authentication failed.", _secondConnectionName); + } + + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Second connection ({_secondConnectionName}) login was not successful, please try again."), cancellationToken); + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + + /// + /// Displays the tokens if the user confirms. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task DisplayTokenPhase1Async(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + _logger.LogInformation("DisplayTokenPhase1Async() method called."); + + await stepContext.Context.SendActivityAsync(MessageFactory.Text("Thank you."), cancellationToken); + + bool result = (bool)stepContext.Result; + if (result) + { + // Pass both tokens to the next step + return await stepContext.NextAsync(null, cancellationToken); + } + + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + + /// + /// Displays both tokens to the user. + /// + /// The waterfall step context. + /// The cancellation token. + /// A task representing the asynchronous operation. + private async Task DisplayTokenPhase2Async(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + _logger.LogInformation("DisplayTokenPhase2Async() method called."); + + // Retrieve both tokens from step context + TokenResponse firstTokenResponse = (TokenResponse)stepContext.Values["FirstToken"]; + TokenResponse secondTokenResponse = (TokenResponse)stepContext.Values["SecondToken"]; + + if (firstTokenResponse != null && secondTokenResponse != null) + { + string tokenMessage = $"Here are your tokens:\n\n" + + $"{_firstConnectionName} Connection Token:\n{firstTokenResponse.Token}\n\n" + + $"{_secondConnectionName} Connection Token:\n{secondTokenResponse.Token}"; + await stepContext.Context.SendActivityAsync(MessageFactory.Text(tokenMessage), cancellationToken); + } + + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + + /// + /// Override to handle logout from both OAuth connections. + /// + protected override async Task OnBeginDialogAsync( + DialogContext innerDc, + object options, + CancellationToken cancellationToken = default) + { + DialogTurnResult? result = await InterruptAsync(innerDc, cancellationToken); + if (result != null) + { + return result; + } + + return await base.OnBeginDialogAsync(innerDc, options, cancellationToken); + } + + /// + /// Override to handle logout from both OAuth connections. + /// + protected override async Task OnContinueDialogAsync( + DialogContext innerDc, + CancellationToken cancellationToken = default) + { + DialogTurnResult? result = await InterruptAsync(innerDc, cancellationToken); + if (result != null) + { + return result; + } + + return await base.OnContinueDialogAsync(innerDc, cancellationToken); + } + + /// + /// Handles logout command by signing out from both OAuth connections. + /// + private async Task InterruptAsync( + DialogContext innerDc, + CancellationToken cancellationToken = default) + { + if (innerDc.Context.Activity.Type == ActivityTypes.Message) + { + string text = innerDc.Context.Activity.Text.ToLowerInvariant(); + + // Allow logout anywhere in the command + if (text.Contains("logout")) + { + // The UserTokenClient encapsulates the authentication processes. + UserTokenClient userTokenClient = innerDc.Context.TurnState.Get(); + + // Sign out from both connections + await userTokenClient.SignOutUserAsync( + innerDc.Context.Activity.From.Id, + _firstConnectionName, + innerDc.Context.Activity.ChannelId, + cancellationToken).ConfigureAwait(false); + + await userTokenClient.SignOutUserAsync( + innerDc.Context.Activity.From.Id, + _secondConnectionName, + innerDc.Context.Activity.ChannelId, + cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("User signed out from both connections: {FirstConnection} and {SecondConnection}", + _firstConnectionName, _secondConnectionName); + + await innerDc.Context.SendActivityAsync( + MessageFactory.Text($"You have been signed out from both connections ({_firstConnectionName} and {_secondConnectionName})."), + cancellationToken); + + return await innerDc.CancelAllDialogsAsync(cancellationToken); + } + } + + return null; + } + } +} diff --git a/core/samples/PABot/InitCompatAdapter.cs b/core/samples/PABot/InitCompatAdapter.cs new file mode 100644 index 000000000..af5daa85a --- /dev/null +++ b/core/samples/PABot/InitCompatAdapter.cs @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; + +namespace PABot +{ + internal static class InitTeamsBotAdapter + { + private const string DefaultScope = "https://api.botframework.com/.default"; + private const string AdapterKeyName = "BotAdapter"; + + /// + /// Configuration values for MSAL identity (bot or agent). + /// + private sealed record MsalIdentityConfig + { + public required IConfigurationSection ConfigSection { get; init; } + public required string ClientId { get; init; } + public required string TenantId { get; init; } + public required string Scope { get; init; } + public required string Instance { get; init; } + } + + /// + /// Configuration values for a bot adapter. + /// + private sealed record AdapterConfig + { + public MsalIdentityConfig? BotIdentity { get; init; } + public MsalIdentityConfig? AgentIdentity { get; init; } + } + + public static IServiceCollection AddTeamsBotApplications(this IServiceCollection services) + { + // Register shared services (needed once for all adapters) + services.AddHttpClient(); + services.AddTokenAcquisition(true); + services.AddInMemoryTokenCaches(); + services.AddAgentIdentities(); + services.AddHttpContextAccessor(); + + // Register adapter using standard MsalBot/MsalAgent configuration + RegisterTeamsBotApplication(services); + + return services; + } + + private static void RegisterTeamsBotApplication(IServiceCollection services) + { + // Read configuration for this adapter + AdapterConfig config = ReadAdapterConfig(services); + + // Set up token validation (authentication schemes and authorization policy) + ConfigureTokenValidation(services, config); + + // Register MSAL options for token acquisition + ConfigureMsalOptions(services, config); + + // Register the routed token acquisition service + RegisterRoutedTokenService(services, config); + + // Register HTTP clients with auth handlers + RegisterHttpClients(services, config); + + // Register Bot Framework clients + RegisterBotClients(services, config); + } + + private static AdapterConfig ReadAdapterConfig(IServiceCollection services) + { + IConfiguration configuration = services.BuildServiceProvider() + .GetRequiredService(); + + IConfigurationSection msalBotSection = configuration.GetSection("MsalBot"); + IConfigurationSection msalAgentSection = configuration.GetSection("MsalAgent"); + + // Read bot identity configuration if provided + MsalIdentityConfig? botIdentity = null; + string? botClientId = msalBotSection["ClientId"]; + if (!string.IsNullOrEmpty(botClientId)) + { + botIdentity = new MsalIdentityConfig + { + ConfigSection = msalBotSection, + ClientId = botClientId, + TenantId = msalBotSection["TenantId"] ?? string.Empty, + Scope = msalBotSection["Scope"] ?? DefaultScope, + Instance = msalBotSection["Instance"] ?? "https://login.microsoftonline.com/" + }; + } + + // Read agent identity configuration if provided + MsalIdentityConfig? agentIdentity = null; + string? agentClientId = msalAgentSection["ClientId"]; + if (!string.IsNullOrEmpty(agentClientId)) + { + agentIdentity = new MsalIdentityConfig + { + ConfigSection = msalAgentSection, + ClientId = agentClientId, + TenantId = msalAgentSection["TenantId"] ?? string.Empty, + Scope = msalAgentSection["Scope"] ?? DefaultScope, + Instance = msalAgentSection["Instance"] ?? botIdentity?.Instance ?? "https://login.microsoftonline.com/" + }; + } + + // At least one identity must be configured + if (botIdentity is null && agentIdentity is null) + { + throw new InvalidOperationException("At least one identity (MsalBot or MsalAgent) must be configured with a ClientId"); + } + + return new AdapterConfig + { + BotIdentity = botIdentity, + AgentIdentity = agentIdentity + }; + } + + private static void ConfigureTokenValidation(IServiceCollection services, AdapterConfig config) + { + // This demonstrates an edge case scenario where two token validation schemes are registered + // with different audiences (client IDs). The authorization policy will succeed if EITHER + // scheme validates successfully - only one token needs to pass, not both. + // Use case: When a bot is also registered as an agentic application and needs to accept + // tokens from both the bot registration AND the agentic application registration. + + AuthenticationBuilder authBuilder = services.AddAuthentication(); + + // Configure authentication schemes for bot identity if present + string? botScheme = null; + if (config.BotIdentity is not null) + { + botScheme = "MsalBot"; + authBuilder.AddBotAuthentication(config.BotIdentity.ClientId, config.BotIdentity.TenantId, botScheme); + } + + // Configure authentication schemes for agent identity if present + string? agentScheme = null; + if (config.AgentIdentity is not null) + { + agentScheme = "MsalAgent"; + authBuilder.AddBotAuthentication(config.AgentIdentity.ClientId, config.AgentIdentity.TenantId, agentScheme); + } + + // Create policy scheme that routes based on token audience + authBuilder.AddPolicyScheme(AdapterKeyName, AdapterKeyName, options => + { + options.ForwardDefaultSelector = context => + SelectAuthenticationScheme(context, config, botScheme, agentScheme); + }); + + // Create authorization policy + services.AddAuthorizationBuilder() + .AddPolicy(AdapterKeyName, policy => + { + policy.AuthenticationSchemes.Add(AdapterKeyName); + policy.RequireAuthenticatedUser(); + }); + } + + private static string SelectAuthenticationScheme( + HttpContext context, + AdapterConfig config, + string? botScheme, + string? agentScheme) + { + // Default to first available scheme + string defaultScheme = botScheme ?? agentScheme ?? throw new InvalidOperationException("No authentication scheme configured"); + + string? authHeader = context.Request.Headers.Authorization.ToString(); + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return defaultScheme; + } + + try + { + string token = authHeader["Bearer ".Length..].Trim(); + JsonWebToken jwt = new(token); + string? audience = jwt.GetClaim("aud")?.Value; + + // Check bot identity + if (config.BotIdentity is not null && botScheme is not null && + (audience == config.BotIdentity.ClientId || audience == $"api://{config.BotIdentity.ClientId}")) + { + return botScheme; + } + + // Check agent identity + if (config.AgentIdentity is not null && agentScheme is not null && + (audience == config.AgentIdentity.ClientId || audience == $"api://{config.AgentIdentity.ClientId}")) + { + return agentScheme; + } + } + catch + { + // If token parsing fails, default to first available scheme + } + + return defaultScheme; + } + + private static void ConfigureMsalOptions(IServiceCollection services, AdapterConfig config) + { + // Configure MSAL options for bot identity if present - bind directly from MsalBot configuration section + if (config.BotIdentity is not null) + { + services.Configure("MsalBot", config.BotIdentity.ConfigSection); + } + + // Configure MSAL options for agent identity if present - bind directly from MsalAgent configuration section + if (config.AgentIdentity is not null) + { + services.Configure("MsalAgent", config.AgentIdentity.ConfigSection); + } + } + + private static void RegisterRoutedTokenService(IServiceCollection services, AdapterConfig config) + { + services.AddSingleton(sp => + { + return new RoutedTokenAcquisitionService( + config.BotIdentity is not null, + config.AgentIdentity is not null, + sp.GetRequiredService(), + sp.GetRequiredService>()); + }); + } + + private static void RegisterHttpClients(IServiceCollection services, AdapterConfig config) + { + services.AddHttpClient("ConversationClient") + .AddHttpMessageHandler(sp => CreatePACustomAuthHandler(sp, config)); + + services.AddHttpClient("UserTokenClient") + .AddHttpMessageHandler(sp => CreatePACustomAuthHandler(sp, config)); + + services.AddHttpClient("TeamsApiClient") + .AddHttpMessageHandler(sp => CreatePACustomAuthHandler(sp, config)); + } + + private static void RegisterBotClients(IServiceCollection services, AdapterConfig config) + { + // Register ConversationClient + services.AddSingleton(sp => + { + HttpClient httpClient = sp.GetRequiredService() + .CreateClient("ConversationClient"); + return new ConversationClient(httpClient, sp.GetRequiredService>()); + }); + + // Register UserTokenClient + services.AddSingleton(sp => + { + HttpClient httpClient = sp.GetRequiredService() + .CreateClient("UserTokenClient"); + return new UserTokenClient( + httpClient, + sp.GetRequiredService(), + sp.GetRequiredService>()); + }); + + + // Register TeamsBotApplication + services.AddSingleton(sp => + { + return new BotApplication( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>() + ); + }); + } + + private static DelegatingHandler CreatePACustomAuthHandler(IServiceProvider sp, AdapterConfig config) + { + // Use bot scope if available, otherwise use agent scope + string? botScope = config.BotIdentity?.Scope; + string? agentScope = config.AgentIdentity?.Scope; + + return new PACustomAuthHandler( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + botScope ?? agentScope ?? DefaultScope, + agentScope, + sp.GetService>()); + } + } +} diff --git a/core/samples/PABot/PABot.csproj b/core/samples/PABot/PABot.csproj new file mode 100644 index 000000000..426c8d09e --- /dev/null +++ b/core/samples/PABot/PABot.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + Never + + + + diff --git a/core/samples/PABot/PACustomAuthHandler.cs b/core/samples/PABot/PACustomAuthHandler.cs new file mode 100644 index 000000000..646e2e5c0 --- /dev/null +++ b/core/samples/PABot/PACustomAuthHandler.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Teams.Core.Schema; + +namespace PABot +{ + internal class PACustomAuthHandler( + IAuthorizationHeaderProvider authorizationHeaderProvider, + IRoutedTokenAcquisitionService routedTokenService, + ILogger logger, + string botScope, + string? agenticScope = null, + IOptions? managedIdentityOptions = null) : DelegatingHandler + { + private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider = authorizationHeaderProvider ?? throw new ArgumentNullException(nameof(authorizationHeaderProvider)); + private readonly IRoutedTokenAcquisitionService _routedTokenService = routedTokenService ?? throw new ArgumentNullException(nameof(routedTokenService)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly string _botScope = botScope ?? throw new ArgumentNullException(nameof(botScope)); + private readonly string _agenticScope = agenticScope ?? botScope; // Default to bot scope if not specified + private readonly IOptions? _managedIdentityOptions = managedIdentityOptions; + + /// + /// Key used to store the agentic identity in HttpRequestMessage options. + /// When set, agentic application credentials will be used instead of bot credentials. + /// + 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 API calls. + /// Routes to either bot credentials or agentic application credentials based on the presence of AgenticIdentity. + /// + /// Optional agentic identity. When provided, agentic application credentials are used. + /// Cancellation token. + /// The authorization header value. + private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticIdentity, CancellationToken cancellationToken) + { + // If agentic identity is provided, use agentic application credentials with agentic scope + if (agenticIdentity is not null && + !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) && + !string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) + { + _logger.LogInformation("Acquiring token using agentic credentials for scope '{Scope}' with AppId '{AppId}' and UserId '{UserId}'.", + _agenticScope, + agenticIdentity.AgenticAppId, + agenticIdentity.AgenticUserId); + + return await _routedTokenService.AcquireTokenForAgenticAsync(agenticIdentity, _agenticScope, cancellationToken).ConfigureAwait(false); + } + + // Otherwise, use bot credentials with bot scope + _logger.LogInformation("Acquiring token using bot credentials for scope: {Scope}", _botScope); + return await _routedTokenService.AcquireTokenForBotAsync(_botScope, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/core/samples/PABot/Program.cs b/core/samples/PABot/Program.cs new file mode 100644 index 000000000..be05cd568 --- /dev/null +++ b/core/samples/PABot/Program.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Teams.Core; +using PABot; +using PABot.Bots; +using PABot.Dialogs; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Register TeamsBotApplication and all dependencies (uses MsalBot and MsalAgent configuration sections) +builder.Services.AddTeamsBotApplications(); + +// Register adapter using the TeamsBotApplication +builder.Services.AddSingleton(sp => +{ + return new AdapterWithErrorHandler( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService()); +}); + +// Register bot state and dialog +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Register bot (pick between TeamsBot & EchoBot) +// builder.Services.AddTransient>(); +// builder.Services.AddTransient(); +builder.Services.AddTransient(); + +WebApplication app = builder.Build(); + +// Map endpoint with BotAdapter authorization policy +app.MapPost("/api/messages", (HttpRequest request, HttpResponse response, IBot bot, CancellationToken ct, IBotFrameworkHttpAdapter adapter) => + adapter.ProcessAsync(request, response, bot, ct)).RequireAuthorization("BotAdapter"); + +app.Run(); diff --git a/core/samples/PABot/Properties/launchSettings.TEMPLATE.json b/core/samples/PABot/Properties/launchSettings.TEMPLATE.json new file mode 100644 index 000000000..1a0ecb596 --- /dev/null +++ b/core/samples/PABot/Properties/launchSettings.TEMPLATE.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "local": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "Logging__LogLevel__Microsoft": "Warning", + "Logging__LogLevel__Microsoft.Teams": "Information", + "Logging__LogLevel__Microsoft.Identity.Web": "Error", + "ConnectionName": "", + "SecondConnectionName": "", + "MsalBot__Scope": "https://api.botframework.com/.default", + "MsalBot__Instance": "https://login.microsoftonline.com/", + "MsalBot__TenantId": "", + "MsalBot__ClientId": "", + "MsalBot__ClientCredentials__0__SourceType": "", + "MsalBot__ClientCredentials__0__ClientSecret": "", + "MsalAgent__ClientId": "", + "MsalAgent__TenantId": "", + "MsalAgent__Scope": "https://botapi.skype.com/.default", + "MsalAgent__ClientCredentials__0__SourceType": "", + "MsalAgent__ClientCredentials__0__ClientSecret": "" + } + } + } + } diff --git a/core/samples/PABot/RoutedTokenAcquisitionService.cs b/core/samples/PABot/RoutedTokenAcquisitionService.cs new file mode 100644 index 000000000..273e4238d --- /dev/null +++ b/core/samples/PABot/RoutedTokenAcquisitionService.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Teams.Core.Schema; + +namespace PABot +{ + /// + /// Token acquisition service that routes to either bot or agentic credentials based on context. + /// + public interface IRoutedTokenAcquisitionService + { + /// + /// Acquires a token using bot (channel) credentials. + /// + /// The scope for the token request. + /// Cancellation token. + /// An access token. + Task AcquireTokenForBotAsync(string scope, CancellationToken cancellationToken = default); + + /// + /// Acquires a token using agentic application credentials. + /// + /// The agentic identity containing AgenticAppId and AgenticUserId. + /// The scope for the token request. + /// Cancellation token. + /// An access token. + Task AcquireTokenForAgenticAsync(AgenticIdentity agenticIdentity, string scope, CancellationToken cancellationToken = default); + } + + /// + /// Implementation of routed token acquisition service for a specific keyed adapter. + /// + public class RoutedTokenAcquisitionService : IRoutedTokenAcquisitionService + { + private readonly bool _hasBotIdentity; + private readonly bool _hasAgentIdentity; + private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider; + private readonly ILogger _logger; + + public RoutedTokenAcquisitionService( + bool hasBotIdentity, + bool hasAgentIdentity, + IAuthorizationHeaderProvider authorizationHeaderProvider, + ILogger logger) + { + _hasBotIdentity = hasBotIdentity; + _hasAgentIdentity = hasAgentIdentity; + _authorizationHeaderProvider = authorizationHeaderProvider; + _logger = logger; + } + + public async Task AcquireTokenForBotAsync(string scope, CancellationToken cancellationToken = default) + { + if (!_hasBotIdentity) + { + throw new InvalidOperationException( + "Bot identity (MsalBot) is not configured. Cannot acquire token using bot credentials. " + + "Either configure MsalBot section in configuration or use AcquireTokenForAgenticAsync instead."); + } + + _logger.LogDebug("Acquiring token for bot credentials using MsalBot configuration"); + + // Use the bot client credentials configuration + return await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync( + scope, + new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + AuthenticationOptionsName = "MsalBot" + } + }, + cancellationToken); + } + + public async Task AcquireTokenForAgenticAsync(AgenticIdentity agenticIdentity, string scope, CancellationToken cancellationToken = default) + { + if (agenticIdentity is null) + { + throw new ArgumentNullException(nameof(agenticIdentity)); + } + + if (string.IsNullOrEmpty(agenticIdentity.AgenticAppId)) + { + throw new ArgumentException("AgenticAppId cannot be null or empty", nameof(agenticIdentity)); + } + + if (string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) + { + throw new ArgumentException("AgenticUserId cannot be null or empty", nameof(agenticIdentity)); + } + + if (!_hasAgentIdentity) + { + throw new InvalidOperationException( + "Agent identity (MsalAgent) is not configured. Cannot acquire token using agent credentials. " + + "Configure MsalAgent section in configuration to use agentic authentication."); + } + + _logger.LogDebug("Acquiring token for agentic credentials with AppId '{AppId}' and UserId '{UserId}'", + agenticIdentity.AgenticAppId, + agenticIdentity.AgenticUserId); + + // Use the agentic client credentials configuration + AuthorizationHeaderProviderOptions options = new() + { + AcquireTokenOptions = new AcquireTokenOptions + { + AuthenticationOptionsName = "MsalAgent" + } + }; + + // Use WithAgentUserIdentity to acquire token with agentic identity + options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, Guid.Parse(agenticIdentity.AgenticUserId)); + + return await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync( + [scope], + options, + null, + cancellationToken); + } + } +} diff --git a/core/samples/PABot/SimpleGraphClient.cs b/core/samples/PABot/SimpleGraphClient.cs new file mode 100644 index 000000000..fdbd3f154 --- /dev/null +++ b/core/samples/PABot/SimpleGraphClient.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Graph; +using Microsoft.Graph.Me.SendMail; +using Microsoft.Graph.Models; +using Microsoft.Kiota.Abstractions.Authentication; + + +namespace PABot +{ + /// + /// This class is a wrapper for the Microsoft Graph API. + /// See: https://developer.microsoft.com/en-us/graph + /// + public class SimpleGraphClient + { + private readonly string _token; + + /// + /// Initializes a new instance of the class. + /// + /// The token issued to the user. + public SimpleGraphClient(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + throw new ArgumentNullException(nameof(token)); + } + + _token = token; + } + + /// + /// Sends an email on the user's behalf using the Microsoft Graph API. + /// + /// The recipient's email address. + /// The subject of the email. + /// The content of the email. + /// A task representing the asynchronous operation. + public async Task SendMailAsync(string toAddress, string subject, string content) + { + if (string.IsNullOrWhiteSpace(toAddress)) + { + throw new ArgumentNullException(nameof(toAddress)); + } + + if (string.IsNullOrWhiteSpace(subject)) + { + throw new ArgumentNullException(nameof(subject)); + } + + if (string.IsNullOrWhiteSpace(content)) + { + throw new ArgumentNullException(nameof(content)); + } + + GraphServiceClient graphClient = GetAuthenticatedClient(); + List recipients = new() + { + new() { + EmailAddress = new EmailAddress + { + Address = toAddress, + }, + }, + }; + + // Create the message. + Message email = new() + { + Body = new ItemBody + { + Content = content, + ContentType = BodyType.Text, + }, + Subject = subject, + ToRecipients = recipients, + }; + + // Send the message. + await graphClient.Me.SendMail.PostAsync(new SendMailPostRequestBody { Message = email, SaveToSentItems = true }); + } + + /// + /// Gets recent mail for the user using the Microsoft Graph API. + /// + /// An array of recent messages. + public async Task GetRecentMailAsync() + { + GraphServiceClient graphClient = GetAuthenticatedClient(); + MessageCollectionResponse? messages = await graphClient.Me.MailFolders["inbox"].Messages.GetAsync(); + + return messages?.Value?.Take(5).ToArray()!; + } + + /// + /// Gets information about the user. + /// + /// The user information. + public async Task GetMeAsync() + { + GraphServiceClient graphClient = GetAuthenticatedClient(); + User? me = await graphClient.Me.GetAsync(); + return me!; + } + + /// + /// Gets the user's photo. + /// + /// The user's photo as a base64 string. + public async Task GetPhotoAsync() + { + GraphServiceClient graphClient = GetAuthenticatedClient(); + + try + { + Stream? photo = await graphClient.Me.Photo.Content.GetAsync(); + if (photo != null) + { + using MemoryStream ms = new(); + await photo.CopyToAsync(ms); + byte[] buffers = ms.ToArray(); + return $"data:image/png;base64,{Convert.ToBase64String(buffers)}"; + } + } + catch { } + + return string.Empty; + } + + /// + /// Gets an authenticated Microsoft Graph client using the token issued to the user. + /// + /// The authenticated GraphServiceClient. + private GraphServiceClient GetAuthenticatedClient() + { + SimpleAccessTokenProvider tokenProvider = new(_token); + + BaseBearerTokenAuthenticationProvider authProvider = new(tokenProvider); + + return new GraphServiceClient(authProvider); + } + + public class SimpleAccessTokenProvider : IAccessTokenProvider + { + private readonly string _accessToken; + + public SimpleAccessTokenProvider(string accessToken) + { + _accessToken = accessToken; + } + + public Task GetAuthorizationTokenAsync(Uri uri, Dictionary? context = null!, CancellationToken cancellationToken = default) + { + return Task.FromResult(_accessToken); + } + + public AllowedHostsValidator AllowedHostsValidator => new(); + } + } +} diff --git a/core/samples/PABot/appsettings.json b/core/samples/PABot/appsettings.json new file mode 100644 index 000000000..2c63c0851 --- /dev/null +++ b/core/samples/PABot/appsettings.json @@ -0,0 +1,2 @@ +{ +} diff --git a/core/samples/Proactive/Proactive.csproj b/core/samples/Proactive/Proactive.csproj new file mode 100644 index 000000000..c0d3289e8 --- /dev/null +++ b/core/samples/Proactive/Proactive.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + diff --git a/core/samples/Proactive/Program.cs b/core/samples/Proactive/Program.cs new file mode 100644 index 000000000..06e80306b --- /dev/null +++ b/core/samples/Proactive/Program.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.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 000000000..d4b446c43 --- /dev/null +++ b/core/samples/Proactive/Worker.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core; +using Microsoft.Teams.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 = CoreActivity.CreateBuilder() + .WithServiceUrl(new Uri(ServiceUrl)) + .WithConversation(new(ConversationId)) + .Build(); + proactiveMessage.From = new ConversationAccount { Id = FromId }; + proactiveMessage.Properties["text"] = $"Proactive hello at {DateTimeOffset.Now}"; + SendActivityResponse? aid = await conversationClient.SendActivityAsync(proactiveMessage, cancellationToken: stoppingToken); + logger.LogInformation("Activity {Aid} sent", aid?.Id ?? "unknown"); + } + await Task.Delay(1000, stoppingToken); + } + } +} diff --git a/core/samples/Proactive/appsettings.json b/core/samples/Proactive/appsettings.json new file mode 100644 index 000000000..e258d2689 --- /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/Quoting/Program.cs b/core/samples/Quoting/Program.cs new file mode 100644 index 000000000..1bb373061 --- /dev/null +++ b/core/samples/Quoting/Program.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); + +// Inbound quoted replies — fires on every message, echoes metadata when a quote is present. +teamsApp.OnMessage(async (context, cancellationToken) => +{ + var quote = context.Activity.GetQuotedMessages().FirstOrDefault()?.QuotedReply; + if (quote == null) return; + + var info = $"Quoted message ID: {quote.MessageId}"; + if (quote.SenderName != null) info += $"\nFrom: {quote.SenderName}"; + if (quote.Preview != null) info += $"\nPreview: \"{quote.Preview}\""; + if (quote.IsReplyDeleted == true) info += "\n(deleted)"; + if (quote.ValidatedMessageReference == true) info += "\n(validated)"; + + await context.SendActivityAsync( + new MessageActivity($"You sent a message with a quoted reply:\n\n{info}") { TextFormat = TextFormats.Markdown }, + cancellationToken); +}); + +// Reply() — auto-quotes the inbound message +teamsApp.OnMessage("(?i)^test reply$", async (context, cancellationToken) => +{ + await context.Reply("Thanks for your message! This reply auto-quotes it.", cancellationToken); +}); + +// Quote() — quote a previously sent message by ID +teamsApp.OnMessage("(?i)^test quote$", async (context, cancellationToken) => +{ + var sent = await context.SendActivityAsync("The meeting has been moved to 3 PM tomorrow.", cancellationToken); + if (sent?.Id != null) + { + await context.Quote(sent.Id, "Just to confirm — does the new time work for everyone?", cancellationToken); + } +}); + +// AddQuote() extension — builder with response +teamsApp.OnMessage("(?i)^test add$", async (context, cancellationToken) => +{ + var sent = await context.SendActivityAsync("Please review the latest PR before end of day.", cancellationToken); + if (sent?.Id != null) + { + MessageActivity msg = new(); + msg.AddQuote(sent.Id, "Done! Left my comments on the PR."); + await context.SendActivityAsync(msg, cancellationToken); + } +}); + +// Multi-quote with mixed responses +teamsApp.OnMessage("(?i)^test multi$", async (context, cancellationToken) => +{ + var sentA = await context.SendActivityAsync("We need to update the API docs before launch.", cancellationToken); + var sentB = await context.SendActivityAsync("The design mockups are ready for review.", cancellationToken); + var sentC = await context.SendActivityAsync("CI pipeline is green on main.", cancellationToken); + + if (sentA?.Id != null && sentB?.Id != null && sentC?.Id != null) + { + MessageActivity msg = new(); + msg.AddQuote(sentA.Id, "I can take the docs — will have a draft by Thursday."); + msg.AddQuote(sentB.Id, "Looks great, approved!"); + msg.AddQuote(sentC.Id); + await context.SendActivityAsync(msg, cancellationToken); + } +}); + +// Builder pattern — WithQuote on TeamsActivityBuilder +teamsApp.OnMessage("(?i)^test builder$", async (context, cancellationToken) => +{ + var sent = await context.SendActivityAsync("Deployment to staging is complete.", cancellationToken); + if (sent?.Id != null) + { + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithQuote(sent.Id, "Verified — all smoke tests passing.") + .Build(); + await context.SendActivityAsync(reply, cancellationToken); + } +}); + +// Help +teamsApp.OnMessage("(?i)^help$", async (context, cancellationToken) => +{ + await context.SendActivityAsync( + new MessageActivity( + "**Quoting Test Bot**\n\n" + + "**Commands:**\n" + + "- `test reply` - Reply() auto-quotes your message\n" + + "- `test quote` - Quote() quotes a previously sent message\n" + + "- `test add` - AddQuote() extension with response\n" + + "- `test multi` - Multi-quote with mixed responses\n" + + "- `test builder` - WithQuote() on TeamsActivityBuilder\n\n" + + "Quote any message to me to see the parsed metadata!") + { TextFormat = TextFormats.Markdown }, + cancellationToken); +}); + +webApp.Run(); diff --git a/core/samples/Quoting/Quoting.csproj b/core/samples/Quoting/Quoting.csproj new file mode 100644 index 000000000..21f55c5d7 --- /dev/null +++ b/core/samples/Quoting/Quoting.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + $(NoWarn);ExperimentalTeamsQuotedReplies + + + + + + + diff --git a/core/samples/Quoting/README.md b/core/samples/Quoting/README.md new file mode 100644 index 000000000..da0c6b244 --- /dev/null +++ b/core/samples/Quoting/README.md @@ -0,0 +1,29 @@ +# Quoting Sample + +Demonstrates various ways to quote previous messages in a Teams bot using the `quotedReply` entity. + +## Prerequisites + +- Bot registered and installed in a chat or channel + +--- + +## Commands + +| Command | Behavior | +|---------|----------| +| `test reply` | `Reply()` — auto-quotes the inbound message | +| `test quote` | `Quote()` — sends a message, then quotes it by ID | +| `test add` | `AddQuote()` — sends a message, then quotes it with extension method + response | +| `test multi` | Sends three messages, then quotes all with interleaved responses | +| `test builder` | `WithQuote()` on `TeamsActivityBuilder` | +| *(quote a message)* | Bot reads and displays the quoted reply metadata | + +--- + +## Running the Sample + +1. Build and run: + ```bash + dotnet run --project samples/Quoting/Quoting.csproj + ``` diff --git a/core/samples/Quoting/appsettings.json b/core/samples/Quoting/appsettings.json new file mode 100644 index 000000000..5febf4fe3 --- /dev/null +++ b/core/samples/Quoting/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/SsoBot/Program.cs b/core/samples/SsoBot/Program.cs new file mode 100644 index 000000000..2672fd5a6 --- /dev/null +++ b/core/samples/SsoBot/Program.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates Teams SSO using the context-level API with a single OAuth connection. +// The context API is the simplest way to add authentication -- when only one OAuthFlow is registered, +// context.SignIn() and context.SignOut() automatically resolve to it without specifying a connection name. +// +// Azure Bot resource must have one OAuth connection setting configured: +// | Connection name | Provider | Scopes | +// |-------------------|-------------|--------------------------| +// | GraphConnection | Azure AD v2 | User.Read Calendars.Read | + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.OAuth; +using Microsoft.Teams.Apps.Schema; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); + +var appBuilder = App.Builder().AddOAuth("sso"); + +webAppBuilder.AddTeams(appBuilder); + +// Configure the single OAuth flow at the DI level +//webAppBuilder.Services.AddTeamsBotApplication(options => +//{ +// options.AddOAuthFlow("sso"); +//}); + +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication bot = webApp.UseTeamsBotApplication(); + +// Get the pre-registered flow and attach callbacks +OAuthFlow auth = bot.GetOAuthFlow("sso"); + +auth.OnSignInComplete(async (context, tokenResponse, ct) => +{ + await context.SendActivityAsync("You're now signed in! Try `profile` or `calendar`.", ct); +}); + +auth.OnSignInFailure(async (context, failure, ct) => +{ + string message = failure is not null + ? $"Sign-in failed: {failure.Code} — {failure.Message}" + : "Sign-in failed. Please try again."; + await context.SendActivityAsync(message, ct); +}); + +// ==================== MESSAGE HANDLERS ==================== + +bot.OnMessage("(?i)^login$", async (context, ct) => +{ + // context.SignIn() resolves to the single registered OAuthFlow automatically + string? token = await context.SignIn(cancellationToken: ct); + if (token is not null) + { + await context.SendActivityAsync("You're already signed in.", ct); + } + // else: OAuthCard sent, SSO flow in progress -- OnSignInComplete will fire +}); + +bot.OnMessage("(?i)^profile$", async (context, ct) => +{ + // SignIn doubles as "get token if cached, else start sign-in" + string? token = await context.SignIn(cancellationToken: ct); + if (token is null) return; // sign-in card sent, wait for completion + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + + try + { + string json = await http.GetStringAsync("https://graph.microsoft.com/v1.0/me", ct); + string indentedJson = JsonSerializer.Serialize(JsonSerializer.Deserialize(json), new JsonSerializerOptions { WriteIndented = true }); + await context.SendActivityAsync(new MessageActivity($" ## Graph Me \n ```json\n{indentedJson}\n```") { TextFormat = TextFormats.Markdown }, ct); + } + catch (HttpRequestException ex) + { + await context.SendActivityAsync($"Graph call failed: {ex.Message}", ct); + } +}); + +bot.OnMessage("(?i)^calendar$", async (context, ct) => +{ + string? token = await context.SignIn(cancellationToken: ct); + if (token is null) return; + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + + try + { + string json = await http.GetStringAsync( + "https://graph.microsoft.com/v1.0/me/events?$top=3&$select=subject,start,end&$orderby=start/dateTime", ct); + string indentedJson = JsonSerializer.Serialize(JsonSerializer.Deserialize(json), new JsonSerializerOptions { WriteIndented = true }); + await context.SendActivityAsync(new MessageActivity($" ## Graph Calendar \n ```json\n{indentedJson}\n```") { TextFormat = TextFormats.Markdown }, ct); + } + catch (HttpRequestException ex) + { + await context.SendActivityAsync($"Graph call failed: {ex.Message}", ct); + } +}); + +bot.OnMessage("(?i)^logout$", async (context, ct) => +{ + await context.SignOut(cancellationToken: ct); + await context.SendActivityAsync("Signed out.", ct); +}); + +bot.OnMessage("(?i)^status$", async (context, ct) => +{ + bool signedIn = await context.IsSignedInAsync(cancellationToken: ct); + await context.SendActivityAsync(signedIn ? "Signed in." : "Not signed in.", ct); +}); + +bot.OnMessage("(?i)^help$", async (context, ct) => +{ + string helpText = """ + **SSO Bot** - Single-connection SSO sample + + Commands: + - `login` - Sign in with SSO + - `profile` - Get your Azure AD profile (signs in if needed) + - `calendar` - Get your next 3 calendar events (signs in if needed) + - `status` - Check sign-in status + - `logout` - Sign out + - `help` - Show this message + """; + + await context.SendActivityAsync( + new MessageActivity(helpText) { TextFormat = TextFormats.Markdown }, ct); +}); + +// ==================== INSTALL HANDLER ==================== + +bot.OnInstall(async (context, ct) => +{ + await context.SendActivityAsync( + new MessageActivity("Welcome to **SSO Bot**! Type `help` to see available commands.") + { + TextFormat = TextFormats.Markdown + }, ct); +}); + +webApp.Run(); diff --git a/core/samples/SsoBot/SsoBot.csproj b/core/samples/SsoBot/SsoBot.csproj new file mode 100644 index 000000000..0f379b438 --- /dev/null +++ b/core/samples/SsoBot/SsoBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/SsoBot/appsettings.json b/core/samples/SsoBot/appsettings.json new file mode 100644 index 000000000..bc73380e4 --- /dev/null +++ b/core/samples/SsoBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Trace" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/StreamingBot/Program.cs b/core/samples/StreamingBot/Program.cs new file mode 100644 index 000000000..6d467c56f --- /dev/null +++ b/core/samples/StreamingBot/Program.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Extensions.AI; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using OpenAI; + +WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); +builder.Services.AddTeamsBotApplication(); + +string apiKey = builder.Configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey is required."); +string modelId = builder.Configuration["OpenAI:ModelId"] ?? throw new InvalidOperationException("OpenAI:ModelId is required."); + +IChatClient chatClient = new OpenAIClient(apiKey) + .GetChatClient(modelId) + .AsIChatClient(); + +WebApplication webApp = builder.Build(); +TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); + +teamsApp.OnMessage(async (context, cancellationToken) => +{ + TeamsStreamingWriter writer = TeamsStreamingWriter.CreateFromContext(context); + await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken); + await Task.Delay(500, cancellationToken); + await writer.SendInformativeUpdateAsync("Thinking again !!!", cancellationToken); + + string userText = context.Activity.Text ?? "Tell me something interesting."; + + await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, userText)], + cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(update.Text)) + await writer.AppendResponseAsync(update.Text, cancellationToken); + } + + await writer.AppendResponseAsync(" [1]", cancellationToken); + + CitationEntity citation = new() + { + AdditionalType = ["AIGeneratedContent"], + Citation = + [ + new CitationClaim + { + Position = 1, + Appearance = new CitationAppearance + { + Name = "OpenAI " + modelId, + Abstract = "Response generated by " + modelId + " via the OpenAI API.", + Url = new Uri("https://platform.openai.com/docs/models"), + Icon = CitationIcon.Text, + }.ToDocument() + } + ] + }; + + TeamsAttachment card = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.5", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Response generated by " + modelId, + ["wrap"] = true, + } + } + }) + .Build(); + + await writer.FinalizeResponseAsync( + attachments: [card], + entities: [citation], + feedbackEnabled: true, + cancellationToken: cancellationToken); +}); + +teamsApp.OnMessageSubmitAction(async (context, cancellationToken) => +{ + await context.SendActivityAsync("You submitted an action with name and value:" + context.Activity.Value?.ActionName + " - " + context.Activity.Value?.ActionValue, cancellationToken); + + return new InvokeResponse(200); +}); + +webApp.Run(); diff --git a/core/samples/StreamingBot/StreamingBot.csproj b/core/samples/StreamingBot/StreamingBot.csproj new file mode 100644 index 000000000..7f0d2d4f5 --- /dev/null +++ b/core/samples/StreamingBot/StreamingBot.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/core/samples/StreamingBot/appsettings.json b/core/samples/StreamingBot/appsettings.json new file mode 100644 index 000000000..9a565e966 --- /dev/null +++ b/core/samples/StreamingBot/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*", + "OpenAI": { + "ApiKey": "", + "ModelId": "" + } +} diff --git a/core/samples/TabApp/Body.cs b/core/samples/TabApp/Body.cs new file mode 100644 index 000000000..c36c6cde6 --- /dev/null +++ b/core/samples/TabApp/Body.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace TabApp; + +public class PostToChatBody +{ + public required string Message { get; set; } + public string? ChatId { get; set; } + public string? ChannelId { get; set; } +} + +public record PostToChatResult(bool Ok); diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs new file mode 100644 index 000000000..6149e54c5 --- /dev/null +++ b/core/samples/TabApp/Program.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Identity.Web; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; +using TabApp; + +WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); +builder.Services.AddBotAuthorization(); +builder.Services.AddConversationClient(); +WebApplication app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// ==================== TABS ==================== + +var contentTypes = new FileExtensionContentTypeProvider(); +app.MapGet("/tabs/test/{*path}", (string? path) => +{ + var root = Path.Combine(Directory.GetCurrentDirectory(), "Web", "bin"); + var full = Path.Combine(root, path ?? "index.html"); + contentTypes.TryGetContentType(full, out var ct); + return Results.File(File.OpenRead(full), ct ?? "text/html"); +}); + +// ==================== SERVER FUNCTIONS ==================== + +app.MapPost("/functions/post-to-chat", async ( + PostToChatBody body, + HttpContext httpCtx, + ConversationClient conversations, + IConfiguration config, + IMemoryCache cache, + ILogger logger, + CancellationToken ct) => +{ + logger.LogInformation("post-to-chat called"); + + var serviceUrl = new Uri("https://smba.trafficmanager.net/teams"); + string conversationId; + + if (body.ChatId is not null) + { + // group chat or 1:1 chat tab — chat ID is the conversation ID + conversationId = body.ChatId; + } + else if (body.ChannelId is not null) + { + // channel tab — post to the channel directly + conversationId = body.ChannelId; + } + else + { + // personal tab — create or reuse a 1:1 conversation + string userId = httpCtx.User.GetObjectId() ?? throw new InvalidOperationException("User object ID claim not found."); + + if (!cache.TryGetValue($"conv:{userId}", out string? cached)) + { + string botId = config["AzureAd:ClientId"] ?? throw new InvalidOperationException("Bot client ID not configured."); + string tenantId = httpCtx.User.GetTenantId() ?? throw new InvalidOperationException("Tenant ID claim not found."); + + CreateConversationResponse res = await conversations.CreateConversationAsync(new ConversationParameters + { + IsGroup = false, + TenantId = tenantId, + Members = [new TeamsConversationAccount { Id = userId }] + }, serviceUrl, cancellationToken: ct); + + cached = res.Id ?? throw new InvalidOperationException("CreateConversation returned no ID."); + cache.Set($"conv:{userId}", cached); + } + + conversationId = cached!; + } + + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Hello from the tab!") + .WithServiceUrl(serviceUrl) + .WithConversation(new TeamsConversation { Id = conversationId! }) + .Build(); + await conversations.SendActivityAsync(activity, cancellationToken: ct); + + return Results.Json(new PostToChatResult(Ok: true)); +}).RequireAuthorization(); + +app.Run(); diff --git a/core/samples/TabApp/README.md b/core/samples/TabApp/README.md new file mode 100644 index 000000000..30b6ba7bc --- /dev/null +++ b/core/samples/TabApp/README.md @@ -0,0 +1,109 @@ +# TabApp + +A sample demonstrating a React/Vite tab served by the bot, with server functions and client-side Graph calls. + +| Feature | How it works | +|---|---| +| **Static tab** | Bot serves `Web/bin` via `app.WithTab("test", "./Web/bin")` at `/tabs/test` | +| **Teams Context** | Reads the raw Teams context via the Teams JS SDK | +| **Post to Chat** | Tab calls `POST /functions/post-to-chat` → bot sends a proactive message | +| **Who Am I** | Acquires a Graph token via MSAL and calls `GET /me` | +| **Toggle Presence** | Acquires a Graph token with `Presence.ReadWrite` and calls `POST /me/presence/setUserPreferredPresence` | + +--- + +## Azure App Registration + +### 1. Application ID URI + +Under **Expose an API → Application ID URI**, set it to: + +``` +api://{YOUR_CLIENT_ID} +``` + +Then add a scope named `access_as_user` and pre-authorize the Teams client IDs: + +| Client ID | App | +|---|---| +| `1fec8e78-bce4-4aaf-ab1b-5451cc387264` | Teams desktop / mobile | +| `5e3ce6c0-2b1f-4285-8d4b-75ee78787346` | Teams web | + +### 2. Redirect URI + +Under **Authentication → Add a platform → Single-page application**, add: + +``` +https://{YOUR_DOMAIN}/tabs/test +``` +and +``` +brk-multihub://{your_domain} +``` + +### 3. API permissions + +Under **API permissions → Add a permission → Microsoft Graph → Delegated**: + +| Permission | Required for | +|---|---| +| `User.Read` | Who Am I | +| `Presence.ReadWrite` | Toggle Presence | + +--- + +## Manifest + +**`webApplicationInfo`** — required for SSO (`authentication.getAuthToken()` and MSAL silent auth): + +```json +"webApplicationInfo": { + "id": "{YOUR_CLIENT_ID}", + "resource": "api://{YOUR_CLIENT_ID}" +} +``` + +**`staticTabs`**: + +```json +"staticTabs": [ + { + "entityId": "tab", + "name": "Tab", + "contentUrl": "https://{YOUR_DOMAIN}/tabs/test", + "websiteUrl": "https://{YOUR_DOMAIN}/tabs/test", + "scopes": ["personal"] + } +] +``` + +--- + +## Configuration + +**`launchSettings.json`** (or environment variables): + +```json +"AzureAD__TenantId": "{YOUR_TENANT_ID}", +"AzureAD__ClientId": "{YOUR_CLIENT_ID}", +"AzureAD__ClientCredentials__0__SourceType": "ClientSecret", +"AzureAd__ClientCredentials__0__ClientSecret": "{YOUR_CLIENT_SECRET}" +``` + +**`Web/.env`**: + +``` +VITE_CLIENT_ID={YOUR_CLIENT_ID} +``` + +--- + +## Build & Run + +```bash +# Build the React app +cd Web && npm install && npm run build + +# Run the bot +dotnet run +``` diff --git a/core/samples/TabApp/TabApp.csproj b/core/samples/TabApp/TabApp.csproj new file mode 100644 index 000000000..4224005cf --- /dev/null +++ b/core/samples/TabApp/TabApp.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/core/samples/TabApp/Web/index.html b/core/samples/TabApp/Web/index.html new file mode 100644 index 000000000..192d27c2b --- /dev/null +++ b/core/samples/TabApp/Web/index.html @@ -0,0 +1,12 @@ + + + + + + Teams Tab + + +
+ + + diff --git a/core/samples/TabApp/Web/package-lock.json b/core/samples/TabApp/Web/package-lock.json new file mode 100644 index 000000000..61e1502b6 --- /dev/null +++ b/core/samples/TabApp/Web/package-lock.json @@ -0,0 +1,1897 @@ +{ + "name": "tabsapp-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tabsapp-web", + "version": "1.0.0", + "dependencies": { + "@azure/msal-browser": "^3.0.0", + "@microsoft/teams-js": "^2.32.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^6.4.2" + } + }, + "node_modules/@azure/msal-browser": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.30.0.tgz", + "integrity": "sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/teams-js": { + "version": "2.48.1", + "resolved": "https://registry.npmjs.org/@microsoft/teams-js/-/teams-js-2.48.1.tgz", + "integrity": "sha512-zL+DzftBSfLnC2r8MK3DdzQBxsbCQcxvHpTO+AkSpxNQw+UD/bpEA1mzhs2r3fqjocjlOLWsSjY8yveNLPUEEA==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "debug": "^4.3.3" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/core/samples/TabApp/Web/package.json b/core/samples/TabApp/Web/package.json new file mode 100644 index 000000000..dfbffb559 --- /dev/null +++ b/core/samples/TabApp/Web/package.json @@ -0,0 +1,22 @@ +{ + "name": "tabsapp-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "@azure/msal-browser": "^3.0.0", + "@microsoft/teams-js": "^2.32.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^6.4.2" + } +} diff --git a/core/samples/TabApp/Web/src/App.css b/core/samples/TabApp/Web/src/App.css new file mode 100644 index 000000000..ec308897d --- /dev/null +++ b/core/samples/TabApp/Web/src/App.css @@ -0,0 +1,161 @@ +/* ─── Design tokens ──────────────────────────────────────────────────────── */ +:root { + --bg: #f5f5f5; /* page background */ + --surface: #ffffff; /* card / elevated surface */ + --text: #111111; + --accent: #6264a7; /* Teams purple */ + --accent-hover: #4f50a0; + --border: #e0e0e0; + --hint: #666; /* secondary / helper text */ +} + +/* ─── OS-level dark mode ─────────────────────────────────────────────────── */ +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1a1a; + --surface: #2d2d2d; + --text: #f0f0f0; + --accent: #9ea5ff; + --accent-hover: #b8bdff; + --border: #444; + --hint: #aaa; + } +} + +/* ─── Teams theme override ───────────────────────────────────────────────── + Teams injects a data-theme attribute on ("default", "dark", + "contrast"). */ +[data-theme='dark'], +[data-theme='contrast'] { + --bg: #1a1a1a; + --surface: #2d2d2d; + --text: #f0f0f0; + --accent: #9ea5ff; + --accent-hover: #b8bdff; + --border: #444; + --hint: #aaa; +} + +/* ─── Reset ──────────────────────────────────────────────────────────────── */ +* { + box-sizing: border-box; +} + +/* ─── Base ───────────────────────────────────────────────────────────────── + Segoe UI is the Teams typeface; the stack falls back gracefully on other + platforms. */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +/* ─── Page layout ────────────────────────────────────────────────────────── + Constrain content width and centre it so the tab reads well on wide + desktop clients. */ +.app { + max-width: 680px; + margin: 0 auto; + padding: 24px 16px; +} + +h1 { + font-size: 1.4rem; + color: var(--accent); + margin: 0 0 20px; +} + +/* ─── Card ───────────────────────────────────────────────────────────────── + Each functional section (post-to-chat, who-am-i, …) lives in a card so + they're visually separated without hard dividers. */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 14px; +} + +.card h2 { + margin: 0 0 8px; + font-size: 0.9rem; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* ─── Helper text ────────────────────────────────────────────────────────── + Short description shown below a card heading. */ +.hint { + margin: 0 0 12px; + font-size: 0.82rem; + color: var(--hint); +} + +/* ─── JSON output ────────────────────────────────────────────────────────── + Used inside .result cards to display raw server responses. */ +pre { + background: var(--bg); + border-radius: 4px; + padding: 10px; + font-size: 0.8rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} + +/* ─── Text input ─────────────────────────────────────────────────────────── + Full-width so it stays aligned with the button below it. */ +input { + display: block; + width: 100%; + padding: 8px 10px; + margin-bottom: 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-size: 0.9rem; +} + +input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* ─── Button ─────────────────────────────────────────────────────────────── + Teams-purple fill; transitions smoothly on hover. */ +button { + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + padding: 8px 18px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: background 0.15s; +} + +button:hover { + background: var(--accent-hover); +} + +/* ─── Loading state ──────────────────────────────────────────────────────── + Shown while Teams SDK initialises (before app.initialize() resolves). */ +.loading { + padding: 60px; + text-align: center; + color: var(--hint); +} + +/* ─── Result card ────────────────────────────────────────────────────────── + Accent-coloured border makes the response stand out from regular cards. */ +.result pre { + border: 1px solid var(--accent); +} diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx new file mode 100644 index 000000000..76080247e --- /dev/null +++ b/core/samples/TabApp/Web/src/App.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect, useCallback } from 'react' +import { app } from '@microsoft/teams-js' +import { createNestablePublicClientApplication, InteractionRequiredAuthError, IPublicClientApplication } from '@azure/msal-browser' + +const clientId = import.meta.env.VITE_CLIENT_ID as string +let _msal: IPublicClientApplication + +//TODO : do we want to take dependency on teams.client +async function getMsal(): Promise { + if (!_msal) { + _msal = await createNestablePublicClientApplication({ + auth: { clientId, authority: '', redirectUri: '/' }, + }) + } + return _msal +} + +async function acquireToken(scopes: string[], context: app.Context | null): Promise { + const loginHint = context?.user?.loginHint + const msal = await getMsal() + + const accounts = msal.getAllAccounts() + const account = loginHint + ? (accounts.find(a => a.username === loginHint) ?? accounts[0]) + : accounts[0] + + try { + if (!account) throw new InteractionRequiredAuthError('no_account') + const result = await msal.acquireTokenSilent({ scopes, account }) + return result.accessToken + } catch (e) { + if (!(e instanceof InteractionRequiredAuthError)) throw e + const result = await msal.acquireTokenPopup({ scopes, loginHint }) + return result.accessToken + } +} + +export default function App() { + const [context, setContext] = useState(null) + const [message, setMessage] = useState('Hello from the tab!') + const [result, setResult] = useState('') + const [initialized, setInitialized] = useState(false) + const [status, setStatus] = useState(false) + + useEffect(() => { + app.initialize().then(() => { + app.getContext().then((ctx) => { + setContext(ctx) + setInitialized(true) + }) + }) + }, []) + + async function callFunction(name: string, body: unknown): Promise { + const msal = await getMsal() + const { accessToken } = await msal.acquireTokenSilent({ scopes: [`api://${clientId}/access_as_user`] }) + + const res = await fetch(`/functions/${name}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + } + + async function run(fn: () => Promise) { + try { + const res = await fn() + setResult(JSON.stringify(res, null, 2)) + } catch (e) { + setResult(String(e)) + } + } + + const showContext = useCallback(() => run(async () => context), [context]) + const postToChat = useCallback(() => run(() => callFunction('post-to-chat', { message, chatId: context?.chat?.id, channelId: context?.channel?.id })), [message, context]) + const whoAmI = useCallback(() => run(async () => { + const accessToken = await acquireToken(['User.Read'], context) + return fetch('https://graph.microsoft.com/v1.0/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }).then(r => r.json()) + }), [context]) + + const toggleStatus = useCallback(() => run(async () => { + const accessToken = await acquireToken(['Presence.ReadWrite'], context) + + const presenceRes = await fetch('https://graph.microsoft.com/v1.0/me/presence', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!presenceRes.ok) throw new Error(`Graph ${presenceRes.status}`) + const { availability: current } = await presenceRes.json() + + const isAvailable = current === 'Available' + const availability = isAvailable ? 'DoNotDisturb' : 'Available' + const activity = isAvailable ? 'DoNotDisturb' : 'Available' + + const res = await fetch('https://graph.microsoft.com/v1.0/me/presence/setUserPreferredPresence', { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ availability, activity }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(`Graph ${res.status}: ${JSON.stringify(body)}`) + } + setStatus(availability === 'DoNotDisturb') + return { availability, activity } + }), [context]) + + if (!initialized) { + return
Initializing Teams SDK…
+ } + + return ( +
+

Teams Tab Sample

+ +
+

Teams Context

+

Shows the raw Teams context for this session.

+ +
+ +
+

Post to Chat

+

Sends a proactive message via the bot.

+ setMessage(e.target.value)} + placeholder="Message text" + /> + +
+ +
+

Who Am I

+

Looks up your member record.

+ +
+ +
+

Toggle Presence

+

Sets your Teams presence via Graph. Current: {status ? 'DoNotDisturb' : 'Available'}

+ +
+ + {result && ( +
+

Result

+
{result}
+
+ )} +
+ ) +} diff --git a/core/samples/TabApp/Web/src/main.tsx b/core/samples/TabApp/Web/src/main.tsx new file mode 100644 index 000000000..df5795603 --- /dev/null +++ b/core/samples/TabApp/Web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './App.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/core/samples/TabApp/Web/tsconfig.json b/core/samples/TabApp/Web/tsconfig.json new file mode 100644 index 000000000..21cb88147 --- /dev/null +++ b/core/samples/TabApp/Web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/core/samples/TabApp/Web/vite.config.ts b/core/samples/TabApp/Web/vite.config.ts new file mode 100644 index 000000000..3ed0f3dbe --- /dev/null +++ b/core/samples/TabApp/Web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + // Must match the tab name passed to app.WithTab("test", ...) + base: '/tabs/test', + build: { + outDir: 'bin', + emptyOutDir: true, + }, +}) diff --git a/core/samples/TabApp/appsettings.json b/core/samples/TabApp/appsettings.json new file mode 100644 index 000000000..5ebb41d07 --- /dev/null +++ b/core/samples/TabApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/TeamsBot/Cards.cs b/core/samples/TeamsBot/Cards.cs new file mode 100644 index 000000000..424e80774 --- /dev/null +++ b/core/samples/TeamsBot/Cards.cs @@ -0,0 +1,1048 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace TeamsBot; + +internal class Cards +{ + public static object ResponseCard(string? feedback) => new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Form Submitted Successfully! ✓", + ["weight"] = "Bolder", + ["size"] = "Large", + ["color"] = "Good" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"You entered: **{feedback ?? "(empty)"}**", + ["wrap"] = true + } + } + }; + + public static object ReactionsCard(string? reactionsAdded, string? reactionsRemoved) => new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Reaction Received", + ["weight"] = "Bolder", + ["size"] = "Medium" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"Reactions Added: {reactionsAdded ?? "(empty)"}", + ["wrap"] = true + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = $"Reactions Removed: {reactionsRemoved ?? "(empty)"}", + ["wrap"] = true + } + } + }; + + public static readonly object TaskModuleLauncherCard = new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Task Module Demo", + ["weight"] = "Bolder", + ["size"] = "Medium" + }, + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Click the button below to open a task module dialog.", + ["wrap"] = true + } + }, + ["actions"] = new JsonArray + { + new JsonObject + { + ["type"] = "Action.Submit", + ["title"] = "Open Task Module", + ["data"] = new JsonObject + { + ["msteams"] = new JsonObject + { + ["type"] = "task/fetch" + } + } + } + } + }; + + public static readonly object TaskModuleFormCard = new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Enter your details:", + ["weight"] = "Bolder", + ["size"] = "Medium" + }, + new JsonObject + { + ["type"] = "Input.Text", + ["id"] = "userName", + ["label"] = "Name", + ["placeholder"] = "Enter your name" + }, + new JsonObject + { + ["type"] = "Input.Text", + ["id"] = "userComment", + ["label"] = "Comment", + ["placeholder"] = "Enter a comment", + ["isMultiline"] = true + } + }, + ["actions"] = new JsonArray + { + new JsonObject + { + ["type"] = "Action.Submit", + ["title"] = "Submit", + ["data"] = new JsonObject + { + ["msteams"] = new JsonObject + { + ["type"] = "task/submit" + } + } + } + } + }; + + public static readonly object FeedbackCardObj = new JsonObject + { + ["type"] = "AdaptiveCard", + ["version"] = "1.4", + ["body"] = new JsonArray + { + new JsonObject + { + ["type"] = "TextBlock", + ["text"] = "Please provide your feedback:", + ["weight"] = "Bolder", + ["size"] = "Medium" + }, + new JsonObject + { + ["type"] = "Input.Text", + ["id"] = "feedback", + ["placeholder"] = "Enter your feedback here", + ["isMultiline"] = true + } + }, + ["actions"] = new JsonArray + { + new JsonObject + { + ["type"] = "Action.Execute", + ["title"] = "Submit Feedback" + } + } + }; + + public static readonly string TimeOffRequestCardJson = """ + { + "type": "AdaptiveCard", + "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Time off request", + "wrap": true, + "size": "Large", + "weight": "Bolder", + "spacing": "None" + }, + { + "type": "Image", + "url": "https://raw.githubusercontent.com/OfficeDev/Microsoft-Teams-Adaptive-Card-Samples/main/samples/time-off-request/assets/hero-image-default.png", + "style": "RoundedCorners", + "targetWidth": "AtMost:Standard" + }, + { + "type": "Image", + "targetWidth": "Wide", + "url": "https://raw.githubusercontent.com/OfficeDev/Microsoft-Teams-Adaptive-Card-Samples/main/samples/time-off-request/assets/hero-wide.png", + "style": "RoundedCorners" + }, + { + "type": "TextBlock", + "text": "Current balance", + "wrap": true, + "weight": "Bolder", + "color": "Accent", + "targetWidth": "AtLeast:Narrow" + }, + { + "type": "Container", + "style": "accent", + "showBorder": true, + "roundedCorners": true, + "items": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "24h", + "wrap": true, + "color": "Accent", + "size": "ExtraLarge", + "weight": "Bolder" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "name": "HeartPulse", + "color": "Accent", + "size": "xSmall" + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "Sick Days", + "wrap": true, + "weight": "Bolder", + "size": "Default" + } + ], + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraSmall" + }, + { + "type": "TextBlock", + "text": "Accrued at eight hours per three months.", + "wrap": true, + "isSubtle": true, + "size": "Small", + "spacing": "ExtraSmall" + } + ] + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "12h", + "wrap": true, + "color": "Accent", + "size": "ExtraLarge", + "weight": "Bolder" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "name": "LeafOne", + "color": "Accent", + "size": "xSmall" + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "Wellness", + "wrap": true, + "weight": "Bolder" + } + ], + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraSmall" + }, + { + "type": "TextBlock", + "text": "One time 5 day affordance for the year", + "wrap": true, + "isSubtle": true, + "size": "Small", + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraLarge" + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "32h", + "wrap": true, + "color": "Accent", + "size": "ExtraLarge", + "weight": "Bolder" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "name": "Beach", + "color": "Accent", + "size": "xSmall" + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "Paid time off", + "wrap": true, + "weight": "Bolder" + } + ], + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraSmall" + }, + { + "type": "TextBlock", + "text": "Accrued at eight hours per one month.", + "wrap": true, + "isSubtle": true, + "size": "Small", + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraLarge" + } + ] + } + ], + "spacing": "ExtraSmall", + "targetWidth": "Wide", + "horizontalAlignment": "Center" + }, + { + "type": "Container", + "style": "accent", + "showBorder": true, + "roundedCorners": true, + "items": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "24h", + "wrap": true, + "color": "Accent", + "size": "ExtraLarge", + "weight": "Bolder" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "name": "HeartPulse", + "color": "Accent", + "size": "xSmall" + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "Sick Days", + "wrap": true, + "weight": "Bolder" + } + ], + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraSmall" + }, + { + "type": "TextBlock", + "text": "Accrued at eight hours per three months.", + "wrap": true, + "isSubtle": true, + "size": "Small", + "spacing": "ExtraSmall" + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "12h", + "wrap": true, + "color": "Accent", + "size": "ExtraLarge", + "weight": "Bolder" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "name": "LeafOne", + "color": "Accent", + "size": "xSmall" + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "Wellness", + "wrap": true, + "weight": "Bolder" + } + ], + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraSmall" + }, + { + "type": "TextBlock", + "text": "One time 5 day affordance for the year", + "wrap": true, + "isSubtle": true, + "size": "Small", + "spacing": "ExtraSmall" + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "32h", + "wrap": true, + "color": "Accent", + "size": "ExtraLarge", + "weight": "Bolder" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "name": "Beach", + "color": "Accent", + "size": "xSmall" + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "Paid time off", + "wrap": true, + "weight": "Bolder" + } + ], + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraSmall" + }, + { + "type": "TextBlock", + "text": "Accrued at eight hours per one month.", + "wrap": true, + "isSubtle": true, + "size": "Small", + "spacing": "ExtraSmall" + } + ] + } + ] + } + ], + "spacing": "ExtraSmall", + "targetWidth": "Standard" + }, + { + "type": "Container", + "style": "accent", + "showBorder": true, + "roundedCorners": true, + "items": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "Icon", + "name": "HeartPulse", + "color": "Accent", + "horizontalAlignment": "Center", + "size": "Small" + }, + { + "type": "TextBlock", + "text": "24h", + "wrap": true, + "size": "Large", + "color": "Accent", + "weight": "Bolder", + "spacing": "None" + }, + { + "type": "TextBlock", + "text": "Sick days", + "wrap": true, + "weight": "Bolder", + "spacing": "None", + "size": "Small", + "color": "Default" + } + ], + "horizontalAlignment": "Center" + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "Icon", + "name": "LeafOne", + "color": "Accent", + "horizontalAlignment": "Center", + "size": "Small" + }, + { + "type": "TextBlock", + "text": "12h", + "wrap": true, + "size": "Large", + "color": "Accent", + "weight": "Bolder", + "spacing": "None" + }, + { + "type": "TextBlock", + "text": "Wellness", + "wrap": true, + "weight": "Bolder", + "spacing": "None", + "size": "Small", + "color": "Default" + } + ], + "horizontalAlignment": "Center" + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "Icon", + "horizontalAlignment": "Center", + "name": "Beach", + "color": "Accent", + "size": "Small" + }, + { + "type": "TextBlock", + "text": "32h", + "wrap": true, + "color": "Accent", + "size": "Large", + "weight": "Bolder", + "spacing": "None" + }, + { + "type": "TextBlock", + "text": "Paid time off", + "wrap": true, + "weight": "Bolder", + "spacing": "None", + "size": "Small", + "color": "Default" + } + ], + "horizontalAlignment": "Center" + } + ] + } + ], + "spacing": "ExtraSmall", + "targetWidth": "Narrow", + "horizontalAlignment": "Center" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "RichTextBlock", + "inlines": [ + { + "type": "TextRun", + "text": "View current balance", + "selectAction": { + "type": "Action.ToggleVisibility", + "targetElements": [ + "vNarrowBalance", + "balanceDown", + "balanceUp" + ] + } + } + ] + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "name": "ChevronDown", + "size": "xxSmall", + "selectAction": { + "type": "Action.ToggleVisibility", + "targetElements": ["vNarrowBalance", "balanceDown", "balanceUp"] + }, + "color": "Accent", + "id": "balanceDown" + }, + { + "type": "Icon", + "isVisible": false, + "id": "balanceUp", + "name": "chevronUp", + "size": "xxSmall", + "color": "Accent", + "selectAction": { + "type": "Action.ToggleVisibility", + "targetElements": ["vNarrowBalance", "balanceDown", "balanceUp"] + } + } + ], + "verticalContentAlignment": "Bottom", + "spacing": "ExtraSmall" + } + ], + "targetWidth": "VeryNarrow" + }, + { + "type": "Container", + "style": "accent", + "bleed": true, + "items": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "40px", + "items": [ + { + "type": "TextBlock", + "text": "24h", + "wrap": true, + "size": "Large", + "weight": "Bolder", + "color": "Accent" + } + ], + "verticalContentAlignment": "Center" + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "color": "Accent", + "name": "HeartPulse", + "size": "xSmall" + } + ], + "verticalContentAlignment": "Center", + "spacing": "Small" + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "Sick days", + "wrap": true, + "weight": "Bolder", + "size": "Small", + "color": "Default" + } + ], + "verticalContentAlignment": "Center", + "spacing": "ExtraSmall" + } + ], + "horizontalAlignment": "Left" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "40px", + "items": [ + { + "type": "TextBlock", + "text": "12h", + "wrap": true, + "weight": "Bolder", + "color": "Accent", + "size": "Large" + } + ], + "verticalContentAlignment": "Center" + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "color": "Accent", + "name": "LeafOne", + "size": "xSmall" + } + ], + "verticalContentAlignment": "Center", + "spacing": "Small" + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "Wellness", + "wrap": true, + "size": "Small", + "weight": "Bolder", + "color": "Default" + } + ], + "verticalContentAlignment": "Center", + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraSmall" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "40px", + "items": [ + { + "type": "TextBlock", + "text": "32h", + "wrap": true, + "weight": "Bolder", + "color": "Accent", + "size": "Large" + } + ], + "verticalContentAlignment": "Center" + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Icon", + "color": "Accent", + "name": "Beach", + "size": "xSmall" + } + ], + "verticalContentAlignment": "Center", + "spacing": "Small" + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "Paid time off", + "wrap": true, + "size": "Small", + "weight": "Bolder", + "color": "Default" + } + ], + "verticalContentAlignment": "Center", + "spacing": "ExtraSmall" + } + ], + "spacing": "ExtraSmall" + } + ], + "spacing": "Small", + "isVisible": false, + "id": "vNarrowBalance", + "targetWidth": "VeryNarrow" + }, + { + "type": "Input.ChoiceSet", + "label": "Reason for leave", + "choices": [ + { + "title": "Vacation", + "value": "Vacation" + }, + { + "title": "Sick Day", + "value": "Sick Day" + }, + { + "title": "Sick Leave", + "value": "Sick Leave" + }, + { + "title": "Other", + "value": "Other" + } + ], + "value": "Vacation", + "id": "leave_reason", + "spacing": "Medium" + }, + { + "type": "Input.Toggle", + "title": "All day (8hrs)", + "id": "all_day", + "spacing": "Small" + }, + { + "type": "Container", + "layouts": [ + { + "type": "Layout.Flow", + "verticalItemsAlignment": "Bottom", + "minItemWidth": "0px", + "itemFit": "Fill" + } + ], + "items": [ + { + "type": "Input.Date", + "label": "Date", + "id": "date", + "isRequired": true, + "errorMessage": "Date is required" + } + ] + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "stretch" + }, + { + "type": "Column", + "width": "stretch", + "spacing": "Small", + "verticalContentAlignment": "Bottom" + } + ] + }, + { + "type": "Input.Number", + "label": "Estimated days off", + "placeholder": "Select how many days", + "id": "days_off", + "spacing": "Medium" + }, + { + "columns": [ + { + "items": [ + { + "inlines": [ + { + "selectAction": { + "targetElements": ["comments", "chevronUp", "chevronDown"], + "type": "Action.ToggleVisibility" + }, + "text": "Add comments", + "type": "TextRun" + } + ], + "targetWidth": "AtLeast:Narrow", + "type": "RichTextBlock" + } + ], + "type": "Column", + "verticalContentAlignment": "Center", + "width": "auto" + }, + { + "items": [ + { + "color": "Accent", + "id": "chevronDown", + "name": "ChevronDown", + "size": "xxSmall", + "type": "Icon" + }, + { + "color": "Accent", + "id": "chevronUp", + "isVisible": false, + "name": "ChevronUp", + "size": "xxSmall", + "spacing": "None", + "type": "Icon" + } + ], + "selectAction": { + "targetElements": ["comments", "chevronUp", "chevronDown"], + "type": "Action.ToggleVisibility" + }, + "spacing": "Small", + "type": "Column", + "verticalContentAlignment": "Center", + "width": "auto" + } + ], + "spacing": "Medium", + "type": "ColumnSet" + }, + { + "type": "Input.Text", + "placeholder": "Enter any comments", + "id": "comments", + "isVisible": false, + "isMultiline": true + }, + { + "type": "ActionSet", + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "style": "positive" + } + ], + "separator": true, + "spacing": "ExtraLarge" + } + ] + } + """; + +} diff --git a/core/samples/TeamsBot/GlobalSuppressions.cs b/core/samples/TeamsBot/GlobalSuppressions.cs new file mode 100644 index 000000000..0d947f509 --- /dev/null +++ b/core/samples/TeamsBot/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "Sample code prioritizes simplicity over GeneratedRegex optimization.")] diff --git a/core/samples/TeamsBot/Program.cs b/core/samples/TeamsBot/Program.cs new file mode 100644 index 000000000..bb08135ee --- /dev/null +++ b/core/samples/TeamsBot/Program.cs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Handlers.TaskModules; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; +using TeamsBot; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication(); + +teamsApp.UseMiddleware(new WelcomeMessageMiddleware()); + + +// ==================== MESSAGE HANDLERS ==================== + +// Help handler: matches "help" (case-insensitive) +teamsApp.OnMessage("(?i)^help$", async (context, cancellationToken) => +{ + await context.SendActivityAsync( + new MessageActivity(WelcomeMessageMiddleware.WelcomeMessage) + { + TextFormat = TextFormats.Markdown + }, cancellationToken); + + + var helpActivity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText(WelcomeMessageMiddleware.WelcomeMessage, TextFormats.Markdown) + .WithSuggestedActions(new SuggestedActions() + { + To = [context.Activity.From?.Id!], + Actions = [ + new SuggestedAction(ActionType.IMBack, "hello") { Value = "hello" }, + new SuggestedAction(ActionType.IMBack, "feedback") { Value = "feedback" }, + ] + }) + .Build(); + + await context.SendActivityAsync(helpActivity, cancellationToken); +}); + +// Pattern-based handler: matches "hello" (case-insensitive) +teamsApp.OnMessage("(?i)hello", async (context, cancellationToken) => +{ + ArgumentNullException.ThrowIfNull(context.Activity.From); + + await context.SendTypingActivityAsync(cancellationToken); + + string replyText = $"You sent: `{context.Activity.Text}`. Type `help` to see available commands."; + + TeamsActivity ta = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText(replyText) + .AddMention(context.Activity.From) + .Build(); + await context.SendActivityAsync(ta, cancellationToken); +}); + +// Markdown handler: matches "markdown" (case-insensitive) +teamsApp.OnMessage("(?i)markdown", async (context, cancellationToken) => +{ + MessageActivity markdownMessage = new(""" +# Markdown Examples + +Here are some **markdown** formatting examples: + +## Text Formatting +- **Bold text** +- *Italic text* +- ~~Strikethrough~~ +- `inline code` + +## Lists +1. First item +2. Second item +3. Third item + +## Code Block +```csharp +public class Example +{ + public string Name { get; set; } +} +``` + +## Links +[Visit Microsoft](https://www.microsoft.com) + +## Quotes +> This is a blockquote +> It can span multiple lines +""") + { + TextFormat = TextFormats.Markdown + }; + + await context.SendActivityAsync(markdownMessage, cancellationToken); +}); + +// Citation handler: matches "citation" (case-insensitive) +teamsApp.OnMessage("(?i)citation", async (context, cancellationToken) => +{ + MessageActivity reply = new("Here is a response with citations [1] [2].") + { + TextFormat = TextFormats.Markdown + }; + + reply.AddCitation(1, new CitationAppearance() + { + Name = "Teams SDK Documentation", + Abstract = "The Teams Bot SDK provides a streamlined way to build bots for Microsoft Teams.", + Url = new Uri("https://github.com/microsoft/teams.net"), + Icon = CitationIcon.Text + }); + + reply.AddCitation(2, new CitationAppearance() + { + Name = "Bot Framework Overview", + Abstract = "Build intelligent bots that interact naturally with users on Teams.", + Keywords = ["bot", "framework"] + }); + + reply.AddAIGenerated(); + reply.AddFeedback(); + + await context.SendActivityAsync(reply, cancellationToken); +}); + +// Targeted message handler: matches "targeted" (case-insensitive) +// Demonstrates send, update, and delete of a targeted message using Recipient.IsTargeted +teamsApp.OnMessage("(?i)targeted", async (context, cancellationToken) => +{ + ArgumentNullException.ThrowIfNull(context.Activity.From); + ArgumentNullException.ThrowIfNull(context.Activity.Conversation); + ArgumentNullException.ThrowIfNull(context.Activity.ServiceUrl); + + // Send a targeted message visible only to the sender + TeamsActivity targeted = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("This is a targeted message only you can see!") + .WithRecipient(context.Activity.From, isTargeted: true) + .Build(); + + var sendResponse = await context.SendActivityAsync(targeted, cancellationToken); + + await Task.Delay(2000, cancellationToken); + + // Update the targeted message (must use UpdateTargetedAsync to avoid setting Recipient on the update payload) + TeamsActivity updated = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("This targeted message was updated!") + .WithServiceUrl(context.Activity.ServiceUrl) + .Build(); + + await context.Api.Conversations.Activities.UpdateTargetedAsync( + context.Activity.Conversation.Id!, + sendResponse!.Id!, + updated, + cancellationToken: cancellationToken); + + await Task.Delay(2000, cancellationToken); + + // Delete the targeted message + await context.Api.Conversations.Activities.DeleteTargetedAsync( + context.Activity.Conversation.Id!, + sendResponse.Id!, + cancellationToken: cancellationToken); +}); + +// Reactions handler: matches "react" (case-insensitive) - adds and removes bot reactions on a message +teamsApp.OnMessage("(?i)^react$", async (context, cancellationToken) => +{ + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(context.Activity); + ArgumentNullException.ThrowIfNull(context.Activity.Conversation); + ArgumentNullException.ThrowIfNull(context.Activity.ServiceUrl); + + var tmMsgToReact = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("I'm going to add and remove reactions to this message.") + .WithRecipient(context.Activity.From, false) + .WithServiceUrl(context.Activity.ServiceUrl) + .Build(); + + var response = await context.SendActivityAsync(tmMsgToReact, cancellationToken); + + await Task.Delay(2000, cancellationToken); + + // Add a waving hand reaction + await context.Api.Conversations.Reactions.AddAsync( + context.Activity.Conversation.Id, + response!.Id!, + "1f44b_wavinghand-tone4", + context.Activity?.Recipient?.GetAgenticIdentity(), + cancellationToken: cancellationToken); + + await Task.Delay(2000, cancellationToken); + + // Add a beaming face reaction + await context.Api.Conversations.Reactions.AddAsync( + context?.Activity?.Conversation?.Id!, + response.Id!, + "1f601_beamingfacewithsmilingeyes", + context?.Activity?.Recipient?.GetAgenticIdentity(), + cancellationToken: cancellationToken); + + await Task.Delay(2000, cancellationToken); + ArgumentNullException.ThrowIfNull(context); + // Remove the beaming face reaction + await context.Api.Conversations.Reactions.DeleteAsync( + context?.Activity?.Conversation?.Id!, + response.Id!, + "1f601_beamingfacewithsmilingeyes", + context?.Activity?.Recipient?.GetAgenticIdentity(), + cancellationToken: cancellationToken); +}); + +// Card handler: matches "card" (case-insensitive) - sends an adaptive card with a feedback form +teamsApp.OnMessage("(?i)^card$", async (context, cancellationToken) => +{ + TeamsAttachment feedbackCard = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(JsonElement.Parse(Cards.TimeOffRequestCardJson)) + .Build(); + MessageActivity feedbackActivity = new([feedbackCard]); + await context.SendActivityAsync(feedbackActivity, cancellationToken); +}); + +// Feedback handler: matches "feedback" (case-insensitive) - sends a feedback card and shows the response via OnAdaptiveCardAction +teamsApp.OnMessage("(?i)^feedback$", async (context, cancellationToken) => +{ + await context.SendActivityAsync("Please fill out the feedback form below:", cancellationToken); + + TeamsAttachment feedbackCard = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.FeedbackCardObj) + .Build(); + MessageActivity feedbackActivity = new([feedbackCard]); + await context.SendActivityAsync(feedbackActivity, cancellationToken); +}); + +// Task handler: matches "task" (case-insensitive) - sends a card that opens a task module +teamsApp.OnMessage("(?i)^task$", async (context, cancellationToken) => +{ + TeamsAttachment taskCard = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.TaskModuleLauncherCard) + .Build(); + MessageActivity taskActivity = new([taskCard]); + await context.SendActivityAsync(taskActivity, cancellationToken); +}); + +teamsApp.OnMessage("(?i)^suggested$", async (context, cancellationToken) => +{ + var suggestedActions = new SuggestedActions() + { + To = [context.Activity.From?.Id!], + Actions = [ + new SuggestedAction(ActionType.IMBack, "Option 1") { Value = "You chose option 1" }, + new SuggestedAction(ActionType.IMBack, "Option 2") { Value = "You chose option 2" }, + new SuggestedAction(ActionType.IMBack, "Option 3") { Value = "You chose option 3" } + ] + }; + + var reply = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Here are some suggested actions for you:") + .WithSuggestedActions(suggestedActions) + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); +}); + +// Regex-based handler: matches commands starting with "/" +Regex commandRegex = Regexes.CommandRegex(); +teamsApp.OnMessage(commandRegex, async (context, cancellationToken) => +{ + Match match = commandRegex.Match(context.Activity.Text ?? ""); + if (match.Success) + { + string command = match.Groups[1].Value; + + string response = command.ToLower() switch + { + "help" => "Available commands: /help, /about, /time", + "about" => "I'm a Teams bot built with the Microsoft Teams Bot SDK!", + "time" => $"Current server time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}", + _ => $"Unknown command: /{command}. Type /help for available commands." + }; + + await context.SendActivityAsync(response, cancellationToken); + } +}); + +// ==================== MESSAGE LIFECYCLE ==================== + +teamsApp.OnMessageUpdate(async (context, cancellationToken) => +{ + string updatedText = context.Activity.Text ?? ""; + MessageActivity reply = new($"I saw that you updated your message to: `{updatedText}`"); + await context.SendActivityAsync(reply, cancellationToken); +}); + +teamsApp.OnMessageReaction(async (context, cancellationToken) => +{ + string reactionsAdded = string.Join(", ", context.Activity.ReactionsAdded?.Select(r => r.Type) ?? []); + string reactionsRemoved = string.Join(", ", context.Activity.ReactionsRemoved?.Select(r => r.Type) ?? []); + + TeamsAttachment reactionsCard = TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.ReactionsCard(reactionsAdded, reactionsRemoved)) + .Build(); + MessageActivity reply = new([reactionsCard]); + + await context.SendActivityAsync(reply, cancellationToken); +}); + +teamsApp.OnMessageDelete(async (context, cancellationToken) => +{ + await context.SendActivityAsync("I saw that message you deleted", cancellationToken); +}); + +// ==================== INVOKE HANDLERS ==================== + +// Adaptive Card action handler: processes feedback form submissions +teamsApp.OnAdaptiveCardAction(async (context, cancellationToken) => +{ + string? feedbackValue = context.Activity.Value?.Action?.Data?["feedback"]?.ToString(); + + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithAttachment(TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.ResponseCard(feedbackValue)) + .Build() + ) + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); + + return AdaptiveCardResponse.CreateMessageResponse("Feedback received!"); +}); + +// Task module fetch: returns an Adaptive Card dialog +teamsApp.OnTaskFetch(async (context, cancellationToken) => +{ + await Task.CompletedTask; + + return TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Continue) + .WithTitle("Task Module Demo") + .WithHeight(TaskModuleSize.Medium) + .WithWidth(TaskModuleSize.Medium) + .WithCard(TeamsAttachment.CreateBuilder() + .WithAdaptiveCard(Cards.TaskModuleFormCard) + .Build()) + .Build(); +}); + +// Task module submit: processes the task module form submission +teamsApp.OnTaskSubmit(async (context, cancellationToken) => +{ + JsonNode? data = context.Activity.Value?.Data is System.Text.Json.JsonElement je + ? JsonNode.Parse(je.GetRawText()) + : null; + + string? name = data?["userName"]?.ToString(); + string? comment = data?["userComment"]?.ToString(); + + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText($"**Task module submitted!**\n- Name: {name ?? "(empty)"}\n- Comment: {comment ?? "(empty)"}") + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); + + return TaskModuleResponse.CreateBuilder() + .WithType(TaskModuleResponseType.Message) + .WithMessage($"Thanks {name ?? "there"}! Your response was recorded.") + .Build(); +}); + +// ==================== EVENT HANDLERS ==================== + +teamsApp.OnEvent(async (context, cancellationToken) => +{ + Console.WriteLine($"[Event] Name: {context.Activity.Name}"); + await context.SendActivityAsync($"Received event: `{context.Activity.Name}`", cancellationToken); +}); + +// ==================== CONVERSATION UPDATE HANDLERS ==================== + +teamsApp.OnMembersAdded(async (context, cancellationToken) => +{ + Console.WriteLine($"[MembersAdded] {context.Activity.MembersAdded?.Count ?? 0} member(s) added"); + + string memberNames = string.Join(", ", context.Activity.MembersAdded?.Select(m => m.Name ?? m.Id) ?? []); + await context.SendActivityAsync($"Welcome! Members added: {memberNames}", cancellationToken); +}); + +teamsApp.OnMembersRemoved(async (context, cancellationToken) => +{ + Console.WriteLine($"[MembersRemoved] {context.Activity.MembersRemoved?.Count ?? 0} member(s) removed"); + + string memberNames = string.Join(", ", context.Activity.MembersRemoved?.Select(m => m.Name ?? m.Id) ?? []); + await context.SendActivityAsync($"Goodbye! Members removed: {memberNames}", cancellationToken); +}); + +// ==================== INSTALL UPDATE HANDLERS ==================== + +teamsApp.OnInstallUpdate(async (context, cancellationToken) => +{ + string action = context.Activity.Action ?? "unknown"; + Console.WriteLine($"[InstallUpdate] Installation action: {action}"); + + if (context.Activity.Action != InstallUpdateActions.Remove) + { + await context.SendActivityAsync($"Installation update: {action}", cancellationToken); + } +}); + +teamsApp.OnInstall(async (context, cancellationToken) => +{ + Console.WriteLine($"[InstallAdd] Bot was installed"); + await context.SendActivityAsync("Thanks for installing me! I'm ready to help.", cancellationToken); +}); + +teamsApp.OnUnInstall((context, cancellationToken) => +{ + Console.WriteLine($"[InstallRemove] Bot was uninstalled"); + return Task.CompletedTask; +}); + +// TODO: This do not trigger from the TimeOffCard submission, need to investigate if it's an issue with the card or the handler +teamsApp.OnMessageSubmitAction(async (context, cancellationToken) => +{ + var actionData = JsonSerializer.Serialize(context.Activity.Value); + await context.SendActivityAsync($"Received submit action with data: {actionData}", cancellationToken); + return new InvokeResponse(200, "Submit Action Received"); +}); + +webApp.Run(); + +partial class Regexes +{ + [GeneratedRegex(@"^/(\w+)(.*)$")] + public static partial Regex CommandRegex(); +} diff --git a/core/samples/TeamsBot/Properties/launchSettings.TEMPLATE.json b/core/samples/TeamsBot/Properties/launchSettings.TEMPLATE.json new file mode 100644 index 000000000..a8990e53e --- /dev/null +++ b/core/samples/TeamsBot/Properties/launchSettings.TEMPLATE.json @@ -0,0 +1,49 @@ +{ + "profiles": { + "TeamsBot-MsalConfig": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "Logging__LogLevel__Microsoft.Teams": "Trace", + "Logging__LogLevel__System.Net.Http.HttpClient": "Warning", + "AzureAd__Instance": "", + "AzureAd__Scope": "", + "AzureAd__ClientId": "", + "AzureAd__TenantId": "", + "AzureAd__ClientCredentials__0__SourceType": "ClientSecret", + "AzureAd__ClientCredentials__0__ClientSecret": "" + } + }, + "TeamsBot-CoreConfig": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "Logging__LogLevel__Microsoft.Teams": "Trace", + "Logging__LogLevel__System.Net.Http.HttpClient": "Warning", + "Scope": "", + "TENANT_ID": "", + "CLIENT_ID": "", + "CLIENT_SECRET": "", + "MANAGED_IDENTITY_CLIENT_ID": "" + } + }, + "TeamsBot-BFConfig": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "Logging__LogLevel__Microsoft.Teams": "Trace", + "Logging__LogLevel__System.Net.Http.HttpClient": "Warning", + "Scope": "", + "MicrosoftAppPassword": "", + "MicrosoftAppId": "", + "MicrosoftAppTenantId": "" + } + } + } +} diff --git a/core/samples/TeamsBot/TeamsBot.csproj b/core/samples/TeamsBot/TeamsBot.csproj new file mode 100644 index 000000000..33003844e --- /dev/null +++ b/core/samples/TeamsBot/TeamsBot.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + $(NoWarn);ExperimentalTeamsTargeted;ExperimentalTeamsReactions + + + + + + + diff --git a/core/samples/TeamsBot/WelcomeMessageMiddleware.cs b/core/samples/TeamsBot/WelcomeMessageMiddleware.cs new file mode 100644 index 000000000..44f84196f --- /dev/null +++ b/core/samples/TeamsBot/WelcomeMessageMiddleware.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Identity.Client; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; + +internal class WelcomeMessageMiddleware : ITurnMiddleware +{ + private bool _hasSentWelcomeMessage = false; + + internal const string WelcomeMessage = +""" +**Teams Bot Demo** + +**Messages** +- `hello` - Greeting +- `markdown` - Markdown formatting demo +- `citation` - AI citations with feedback +- `targeted` - Targeted message lifecycle(send, update, delete) +- `react` - Bot reactions(add, remove) +- `card` - Send an Adaptive Card with a feedback form +- `feedback` - Feedback form with Adaptive Card action round-trip +- `task` - Open a task module dialog +- `suggested` - Suggested actions + +** Commands** +- `/help` - Available slash commands +- `/about` - About this bot +- `/time` - Current server time + +** Lifecycle** *(automatic)* +- Message edits, deletes, and reactions are detected +- Member join/leave and install/uninstall events are handled +"""; + + public async Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn nextTurn, CancellationToken cancellationToken = default) + { + if (!_hasSentWelcomeMessage) + { + var welcomeActivity = TeamsActivity.CreateBuilder() + .WithType("message") + .WithText(WelcomeMessage, TextFormats.Markdown) + .WithConversationReference(TeamsActivity.FromActivity(activity)) + .Build(); + + await botApplication.SendActivityAsync(welcomeActivity, cancellationToken: cancellationToken); + + _hasSentWelcomeMessage = true; + } + if (nextTurn is not null) + { + await nextTurn(cancellationToken); + } + } +} diff --git a/core/samples/TeamsBot/appsettings.json b/core/samples/TeamsBot/appsettings.json new file mode 100644 index 000000000..5febf4fe3 --- /dev/null +++ b/core/samples/TeamsBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/samples/TeamsChannelBot/Program.cs b/core/samples/TeamsChannelBot/Program.cs new file mode 100644 index 000000000..eabdf10b9 --- /dev/null +++ b/core/samples/TeamsChannelBot/Program.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Handlers; + +WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); +webAppBuilder.Services.AddTeamsBotApplication(); +WebApplication webApp = webAppBuilder.Build(); + +TeamsBotApplication app = webApp.UseTeamsBotApplication(); + + +app.OnConversationUpdate(async (context, cancellationToken) => + { + Console.WriteLine($"[ConversationUpdate] Conversation updated"); + } +); + +// ==================== CHANNEL EVENT HANDLERS ==================== + +app.OnChannelCreated(async (context, cancellationToken) => +{ + string channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelCreated] Channel '{channelName}' was created"); + await context.SendActivityAsync($"New channel created: {channelName}", cancellationToken); +}); + +app.OnChannelDeleted(async (context, cancellationToken) => +{ + string channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelDeleted] Channel '{channelName}' was deleted"); + await context.SendActivityAsync($"Channel deleted: {channelName}", cancellationToken); +}); + +app.OnChannelRenamed(async (context, cancellationToken) => +{ + string channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelRenamed] Channel renamed to '{channelName}'"); + await context.SendActivityAsync($"Channel renamed to: {channelName}", cancellationToken); +}); + +app.OnChannelMemberAdded(async (context, cancellationToken) => +{ + Console.WriteLine($"[ChannelMemberAdded] Member added to channel"); + await context.SendActivityAsync("A member was added to the channel", cancellationToken); +}); + +app.OnChannelMemberRemoved(async (context, cancellationToken) => +{ + Console.WriteLine($"[ChannelMemberRemoved] Member removed from channel"); + await context.SendActivityAsync("A member was removed from the channel", cancellationToken); +}); + +app.OnChannelShared(async (context, cancellationToken) => +{ + var channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelShared] Channel '{channelName}' was shared"); + await context.SendActivityAsync($"Channel shared: {channelName}", cancellationToken); +}); + +app.OnChannelUnshared(async (context, cancellationToken) => +{ + var channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelUnshared] Channel '{channelName}' was unshared"); + await context.SendActivityAsync($"Channel unshared: {channelName}", cancellationToken); +}); + +/* +//not able to test - no activity received +app.OnChannelRestored(async (context, cancellationToken) => +{ + var channelName = context.Activity.ChannelData?.Channel?.Name ?? "unknown"; + Console.WriteLine($"[ChannelRestored] Channel '{channelName}' was restored"); + await context.SendActivityAsync($"Channel restored: {channelName}", cancellationToken); +}); +*/ + +// ==================== TEAM EVENT HANDLERS ==================== + +app.OnTeamMemberAdded(async (context, cancellationToken) => +{ + Console.WriteLine($"[TeamMemberAdded] Member added to team"); + await context.SendActivityAsync("A member was added to the team", cancellationToken); +}); + +app.OnTeamMemberRemoved(async (context, cancellationToken) => +{ + Console.WriteLine($"[TeamMemberRemoved] Member removed from team"); + await context.SendActivityAsync("A member was removed from the team", cancellationToken); +}); + +app.OnTeamArchived((context, cancellationToken) => +{ + string teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamArchived] Team '{teamName}' was archived"); + return Task.CompletedTask; +}); + +app.OnTeamDeleted((context, cancellationToken) => +{ + string teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamDeleted] Team '{teamName}' was deleted"); + return Task.CompletedTask; +}); + +app.OnTeamRenamed(async (context, cancellationToken) => +{ + string teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamRenamed] Team renamed to '{teamName}'"); + await context.SendActivityAsync($"Team renamed to: {teamName}", cancellationToken); +}); + +app.OnTeamUnarchived(async (context, cancellationToken) => +{ + string teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamUnarchived] Team '{teamName}' was unarchived"); + await context.SendActivityAsync($"Team unarchived: {teamName}", cancellationToken); +}); +/* +// how to test ? +app.OnTeamHardDeleted((context, cancellationToken) => +{ + var teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamHardDeleted] Team '{teamName}' was permanently deleted"); + return Task.CompletedTask; +}); + +// how to test ? Restore is unarchived +app.OnTeamRestored(async (context, cancellationToken) => +{ + var teamName = context.Activity.ChannelData?.Team?.Name ?? "unknown"; + Console.WriteLine($"[TeamRestored] Team '{teamName}' was restored"); + await context.SendActivityAsync($"Team restored: {teamName}", cancellationToken); +}); +*/ + +webApp.Run(); diff --git a/core/samples/TeamsChannelBot/README.md b/core/samples/TeamsChannelBot/README.md new file mode 100644 index 000000000..3b3b4eefa --- /dev/null +++ b/core/samples/TeamsChannelBot/README.md @@ -0,0 +1,55 @@ +# TeamsChannelBot Sample + +Demonstrates handling `ConversationUpdate` channel and team events in a Teams bot. + +## Prerequisites + +- Bot registered and installed in a team +- Admin permissions in the team to perform most actions + +--- + +## Manifest + +For adding a bot to a shared channel, add `"supportsChannelFeatures": "tier1"` to the root in your `manifest.json`: + +```json +"supportsChannelFeatures": "tier1" +``` + +--- + +## How to Trigger Each Event + +### Channel Events + +| Event | How to Trigger | +|---|---| +| `channelCreated` | In a team where the bot is installed: **Manage team → Channels → Add channel** | +| `channelDeleted` | **Delete channel** | +| `channelRenamed` | **Edit channel** → change name | +| `channelMemberAdded` | In a shared channel: **Share Channel → With people ** | +| `channelMemberRemoved` | In a shared channel: **Manage Channel → Members** → Remove member | +| `channelShared` | In a shared channel: **Share channel → With a team you own** | +| `channelUnshared` | In a shared channel: **Manage channe → Teams** → Remove team | + +### Team Events + +| Event | How to Trigger | +|---|---| +| `teamMemberAdded` | **Add member** | +| `teamMemberRemoved` | **Manage team → Members** → remove a member | +| `teamArchived` |**Archive team** | +| `teamUnarchived` | **Restore team** | +| `teamRenamed` | **Manage team → Settings** → edit team name | +| `teamDeleted` | **Delete team | +--- + +## Running the Sample + +1. Build and run: + ```bash + dotnet run --project samples/TeamsChannelBot/TeamsChannelBot.csproj + ``` + +--- diff --git a/core/samples/TeamsChannelBot/TeamsChannelBot.csproj b/core/samples/TeamsChannelBot/TeamsChannelBot.csproj new file mode 100644 index 000000000..edd383c6e --- /dev/null +++ b/core/samples/TeamsChannelBot/TeamsChannelBot.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/core/samples/TeamsChannelBot/appsettings.json b/core/samples/TeamsChannelBot/appsettings.json new file mode 100644 index 000000000..5febf4fe3 --- /dev/null +++ b/core/samples/TeamsChannelBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/src/Directory.Build.props b/core/src/Directory.Build.props new file mode 100644 index 000000000..cb545cd73 --- /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 000000000..7d5f7f8ef --- /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.Apps.BotBuilder/ActivitySchemaMapper.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/ActivitySchemaMapper.cs new file mode 100644 index 000000000..3722729a9 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/ActivitySchemaMapper.cs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Microsoft.Bot.Builder.Integration.AspNet.Core.Handlers; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Core.Schema; +using Newtonsoft.Json; + +namespace Microsoft.Teams.Apps.BotBuilder; + +/// +/// Extension methods for converting between Bot Framework Activity and CoreActivity/TeamsActivity. +/// +public static class ActivitySchemaMapper +{ + private static string? GetStringValue(object? value) => value switch + { + null => null, + string s => s, + JsonElement { ValueKind: JsonValueKind.String } el => el.GetString(), + JsonElement el => el.GetRawText(), + _ => value.ToString() + }; + /// + /// Converts a to a Bot Framework . + /// + /// The core activity to convert. + /// The equivalent Bot Framework activity. + public static Activity ToBotFrameworkActivity(this CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + using JsonTextReader reader = new(new StringReader(activity.ToJson())); + return BotMessageHandlerBase.BotMessageSerializer.Deserialize(reader)!; + } + + /// + /// Converts a Bot Framework to a . + /// + /// The Bot Framework activity to convert. + /// The equivalent core activity. + public static CoreActivity FromBotFrameworkActivity(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(); + return CoreActivity.FromJsonString(jsonString); + } + + + /// + /// Converts a to a Bot Framework . + /// + /// The conversation account to convert. + /// The equivalent channel account. + public static Microsoft.Bot.Schema.ChannelAccount ToCompatChannelAccount(this Microsoft.Teams.Core.Schema.ConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + Microsoft.Bot.Schema.ChannelAccount channelAccount; + + channelAccount = new() + { + Id = account.Id, + Name = account.Name + }; + + + if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) + { + channelAccount.AadObjectId = GetStringValue(aadObjectId); + } + + if (account.Properties.TryGetValue("userRole", out object? userRole)) + { + channelAccount.Role = GetStringValue(userRole); + } + + if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) + { + channelAccount.Properties.Add("userPrincipalName", GetStringValue(userPrincipalName) ?? string.Empty); + } + + if (account.Properties.TryGetValue("givenName", out object? givenName)) + { + channelAccount.Properties.Add("givenName", GetStringValue(givenName) ?? string.Empty); + } + + if (account.Properties.TryGetValue("surname", out object? surname)) + { + channelAccount.Properties.Add("surname", GetStringValue(surname) ?? string.Empty); + } + + if (account.Properties.TryGetValue("email", out object? email)) + { + channelAccount.Properties.Add("email", GetStringValue(email) ?? string.Empty); + } + + if (account.Properties.TryGetValue("tenantId", out object? tenantId)) + { + channelAccount.Properties.Add("tenantId", GetStringValue(tenantId) ?? string.Empty); + } + + return channelAccount; + } + + /// + /// Converts a to a with all known properties. + /// + /// The conversation account to convert. + /// The equivalent Teams channel account. + public static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChannelAccount2(this Microsoft.Teams.Core.Schema.ConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + return new Microsoft.Bot.Schema.Teams.TeamsChannelAccount + { + Id = account.Id, + Name = account.Name, + AadObjectId = GetStringValue(account.Properties["aadObjectId"]) ?? string.Empty, + Email = GetStringValue(account.Properties["email"]) ?? string.Empty, + GivenName = GetStringValue(account.Properties["givenName"]) ?? string.Empty, + Surname = GetStringValue(account.Properties["surname"]) ?? string.Empty, + UserPrincipalName = GetStringValue(account.Properties["userPrincipalName"]) ?? string.Empty, + UserRole = GetStringValue(account.Properties["userRole"]) ?? string.Empty, + TenantId = GetStringValue(account.Properties["tenantId"]) ?? string.Empty + }; + } + + + /// + /// Converts a Core PagedMembersResult to a Bot Framework TeamsPagedMembersResult. + /// + /// The paged members result to convert. + /// The equivalent Bot Framework paged members result. + public static Microsoft.Bot.Schema.Teams.TeamsPagedMembersResult ToCompatTeamsPagedMembersResult(this Microsoft.Teams.Core.PagedMembersResult pagedMembers) + { + ArgumentNullException.ThrowIfNull(pagedMembers); + + return new Microsoft.Bot.Schema.Teams.TeamsPagedMembersResult + { + ContinuationToken = pagedMembers.ContinuationToken, + Members = pagedMembers.Members?.Select(m => m.ToCompatTeamsChannelAccount()).ToList() + }; + } + + /// + /// Converts a to a . + /// + /// The conversation account to convert. + /// The equivalent Teams channel account. + public static Microsoft.Bot.Schema.Teams.TeamsChannelAccount ToCompatTeamsChannelAccount(this Microsoft.Teams.Core.Schema.ConversationAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + TeamsChannelAccount teamsChannelAccount = new() + { + Id = account.Id, + Name = account.Name + }; + + // Extract properties from Properties dictionary + if (account.Properties.TryGetValue("aadObjectId", out object? aadObjectId)) + { + teamsChannelAccount.AadObjectId = GetStringValue(aadObjectId); + } + + if (account.Properties.TryGetValue("userPrincipalName", out object? userPrincipalName)) + { + teamsChannelAccount.UserPrincipalName = GetStringValue(userPrincipalName); + } + + if (account.Properties.TryGetValue("givenName", out object? givenName)) + { + teamsChannelAccount.GivenName = GetStringValue(givenName); + } + + if (account.Properties.TryGetValue("surname", out object? surname)) + { + teamsChannelAccount.Surname = GetStringValue(surname); + } + + if (account.Properties.TryGetValue("email", out object? email)) + { + teamsChannelAccount.Email = GetStringValue(email); + } + + if (account.Properties.TryGetValue("tenantId", out object? tenantId)) + { + teamsChannelAccount.Properties.Add("tenantId", GetStringValue(tenantId) ?? string.Empty); + } + + return teamsChannelAccount; + } + + /// + /// Converts a Bot Framework ChannelAccount to a Core ConversationAccount. + /// + public static Microsoft.Teams.Core.Schema.ConversationAccount FromCompatChannelAccount(this Microsoft.Bot.Schema.ChannelAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + Microsoft.Teams.Core.Schema.ConversationAccount result = new() { Id = account.Id, Name = account.Name }; + + if (!string.IsNullOrEmpty(account.AadObjectId)) + { + result.Properties["aadObjectId"] = account.AadObjectId; + } + + if (!string.IsNullOrEmpty(account.Role)) + { + result.Properties["userRole"] = account.Role; + } + + return result; + } + + /// + /// Converts a Bot Framework ConversationParameters to a Core ConversationParameters. + /// + public static Microsoft.Teams.Core.ConversationParameters FromCompatConversationParameters(this Microsoft.Bot.Schema.ConversationParameters parameters) + { + ArgumentNullException.ThrowIfNull(parameters); + + return new Microsoft.Teams.Core.ConversationParameters + { + IsGroup = parameters.IsGroup, + Bot = parameters.Bot?.FromCompatChannelAccount(), + Members = parameters.Members?.Select(m => m.FromCompatChannelAccount()).ToList(), + TopicName = parameters.TopicName, + Activity = parameters.Activity?.FromBotFrameworkActivity(), + ChannelData = parameters.ChannelData, + TenantId = parameters.TenantId, + }; + } + + /// + /// Gets the TeamInfo object from the current activity. + /// + /// The activity. + /// The current activity's team's information, or null. + public static TeamInfo? TeamsGetTeamInfo(this IActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + Microsoft.Bot.Schema.Teams.TeamsChannelData channelData = activity.GetChannelData(); + return channelData?.Team; + } + + +} diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/CompatConnectorClient.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatConnectorClient.cs new file mode 100644 index 000000000..d553e2e67 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatConnectorClient.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Connector; +using Microsoft.Rest; +using Newtonsoft.Json; + +namespace Microsoft.Teams.Apps.BotBuilder +{ + /// + /// Provides a stub implementation of for compatibility with Bot Framework SDK. + /// + /// + /// This class serves as a minimal adapter to satisfy Bot Framework's requirement for an IConnectorClient instance. + /// Only the property is implemented; all other members throw . + /// This design allows legacy bots to access conversation operations through the CompatConversations adapter without + /// requiring full implementation of unused connector client features. + /// + /// The conversations adapter that handles conversation-related operations. + internal sealed class CompatConnectorClient(CompatConversations conversations) : IConnectorClient + { + /// + /// Gets the conversations interface for managing bot conversations. + /// + 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() + { + // No resources to dispose; method required by IConnectorClient. + } + } +} diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/CompatConversations.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatConversations.cs new file mode 100644 index 000000000..33e0d4c68 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatConversations.cs @@ -0,0 +1,433 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; +using Microsoft.Rest; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.BotBuilder +{ + /// + /// Provides a compatibility adapter that bridges the Teams Bot Core to the + /// Bot Framework's class. + /// + /// + /// This adapter enables legacy Bot Framework bots to use the new Teams Bot Core conversation management + /// without code changes. It converts between Bot Framework and Core activity formats, handles HTTP operation + /// responses, and manages custom header translations. All operations delegate to the underlying Core ConversationClient. + /// + /// The underlying Teams Bot Core ConversationClient that performs the actual conversation operations. + internal sealed class CompatConversations(ConversationClient client) : IConversations + { + internal readonly ConversationClient _client = client; + + /// + /// Gets or sets the service URL for the bot service endpoint. + /// This URL is used for all conversation operations and must be set before making API calls. + /// + internal string? ServiceUrl { get; set; } + + /// + /// Gets or sets the agentic identity extracted from the incoming activity. + /// Used for user-delegated token acquisition when the bot acts on behalf of an agentic app. + /// + internal AgenticIdentity? AgenticIdentity { get; set; } + + /// + /// Creates a new conversation with the specified parameters. + /// + /// The conversation parameters including members and activity. Cannot be null. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response with + /// a containing the conversation ID, activity ID, and service URL. + /// + /// Thrown when is null or whitespace. + public async Task> CreateConversationWithHttpMessagesAsync( + Microsoft.Bot.Schema.ConversationParameters parameters, + Dictionary>? customHeaders = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + + Microsoft.Teams.Core.ConversationParameters convoParams = parameters.FromCompatConversationParameters(); + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CreateConversationResponse res = await _client.CreateConversationAsync( + convoParams, + new Uri(ServiceUrl), + AgenticIdentity.FromAccount(convoParams.Activity?.From), + 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) + }; + } + + /// + /// Deletes an existing activity from a conversation. + /// + /// The unique identifier of the conversation. Cannot be null or whitespace. + /// The unique identifier of the activity to delete. Cannot be null or whitespace. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response. + /// Thrown when is null or whitespace. + 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), + AgenticIdentity, + 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), + AgenticIdentity, + 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!), + AgenticIdentity, + 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) + }; + } + + /// + /// Retrieves the list of members participating in a conversation. + /// + /// The unique identifier of the conversation. Cannot be null or whitespace. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response with + /// a list of objects representing the conversation members. + /// + /// Thrown when is null or whitespace. + 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), + AgenticIdentity, + 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.Core.PagedMembersResult pagedMembers = await _client.GetConversationPagedMembersAsync( + conversationId, + new Uri(ServiceUrl), + pageSize, + continuationToken, + AgenticIdentity, + 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ServiceUrl); + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + GetConversationsResponse conversations = await _client.GetConversationsAsync( + new Uri(ServiceUrl), + continuationToken, + AgenticIdentity, + 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.FromBotFrameworkActivity(); + + // Default to the ServiceUrl from the adapter if it's not set on the activity, as ConversationClient requires it for sending activities + if (!string.IsNullOrWhiteSpace(ServiceUrl) && coreActivity.ServiceUrl == null) + { + coreActivity.ServiceUrl = new Uri(ServiceUrl); + } + + coreActivity.ReplyToId = activityId; + coreActivity.Conversation = new Microsoft.Teams.Core.Schema.Conversation(conversationId); + + SendActivityResponse? response = await _client.SendActivityAsync(coreActivity, customHeaders: convertedHeaders, cancellationToken: cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response?.Id ?? string.Empty + }; + + 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.Core.Transcript coreTranscript = new() + { + Activities = transcript.Activities?.Select(a => a.FromBotFrameworkActivity()).ToList() + }; + + SendConversationHistoryResponse response = await _client.SendConversationHistoryAsync( + conversationId, + coreTranscript, + new Uri(ServiceUrl), + AgenticIdentity, + 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) + }; + } + + /// + /// Sends an activity to an existing conversation. + /// + /// The unique identifier of the conversation. Cannot be null or whitespace. + /// The activity to send. Cannot be null. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response with + /// a containing the ID of the sent activity. + /// + public async Task> SendToConversationWithHttpMessagesAsync(string conversationId, Activity activity, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CoreActivity coreActivity = activity.FromBotFrameworkActivity(); + + // Default to the ServiceUrl from the adapter if it's not set on the activity, as ConversationClient requires it for sending activities + if (!string.IsNullOrWhiteSpace(ServiceUrl) && coreActivity.ServiceUrl == null) + { + coreActivity.ServiceUrl = new Uri(ServiceUrl); + } + + coreActivity.Conversation = new Microsoft.Teams.Core.Schema.Conversation(conversationId); + + SendActivityResponse? response = await _client.SendActivityAsync(coreActivity, customHeaders: convertedHeaders, cancellationToken: cancellationToken).ConfigureAwait(false); + + ResourceResponse resourceResponse = new() + { + Id = response?.Id ?? string.Empty + }; + + return new HttpOperationResponse + { + Body = resourceResponse, + Response = new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + }; + } + + /// + /// Updates an existing activity in a conversation. + /// + /// The unique identifier of the conversation. Cannot be null or whitespace. + /// The unique identifier of the activity to update. Cannot be null or whitespace. + /// The updated activity content. Cannot be null. + /// Optional custom HTTP headers to include in the request. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an HTTP operation response with + /// a containing the ID of the updated activity. + /// + public async Task> UpdateActivityWithHttpMessagesAsync(string conversationId, string activityId, Activity activity, Dictionary>? customHeaders = null, CancellationToken cancellationToken = default) + { + Dictionary? convertedHeaders = ConvertHeaders(customHeaders); + + CoreActivity coreActivity = activity.FromBotFrameworkActivity(); + + // Default to the ServiceUrl from the adapter if it's not set on the activity, as ConversationClient requires it for updating activities + if (!string.IsNullOrWhiteSpace(ServiceUrl) && coreActivity.ServiceUrl == null) + { + coreActivity.ServiceUrl = new Uri(ServiceUrl); + } + + UpdateActivityResponse response = await _client.UpdateActivityAsync(conversationId, activityId, coreActivity, customHeaders: convertedHeaders, cancellationToken: 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.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), + AgenticIdentity, + 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.Core.Schema.ConversationAccount response = await _client.GetConversationMemberAsync( + conversationId, userId, new Uri(ServiceUrl), AgenticIdentity, 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.Apps.BotBuilder/CompatHostingExtensions.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatHostingExtensions.cs new file mode 100644 index 000000000..27e85d603 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatHostingExtensions.cs @@ -0,0 +1,49 @@ +// 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.Core.Hosting; + +namespace Microsoft.Teams.Apps.BotBuilder; + +/// +/// 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 AddTeamsBotFrameworkHttpAdapter(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddTeamsBotFrameworkHttpAdapter(); + 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 AddTeamsBotFrameworkHttpAdapter(this IServiceCollection services) + { + services.AddBotApplication(); + services.AddSingleton(); + return services; + } +} diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/CompatTeamsInfo.Models.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatTeamsInfo.Models.cs new file mode 100644 index 000000000..83a5bce7a --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatTeamsInfo.Models.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; + +namespace Microsoft.Teams.Apps.BotBuilder; + +internal static class TeamsApiClientModels +{ + /// + /// Gets the TeamsMeetingInfo object from the current activity. + /// + /// The activity. + /// The current activity's meeting information, or null. + public static TeamsMeetingInfo? TeamsGetMeetingInfo(this IActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + TeamsChannelData channelData = activity.GetChannelData(); + return channelData?.Meeting; + } + + 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; } + } + + 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; } + } + + /// + /// 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; } + } +} diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/CompatUserTokenClient.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatUserTokenClient.cs new file mode 100644 index 000000000..816d9f000 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/CompatUserTokenClient.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Schema; +using Microsoft.Teams.Core; + +namespace Microsoft.Teams.Apps.BotBuilder; + +/// +/// Provides a compatibility layer that adapts the Teams Bot Core to the Bot Framework's +/// interface. +/// +/// +/// This adapter enables legacy Bot Framework bots to use the new Teams Bot Core token management system +/// without code changes. It converts between the two different token result formats and delegates all operations +/// to the underlying Core UserTokenClient. +/// +/// The underlying Teams Bot Core UserTokenClient that performs the actual token operations. +internal sealed class CompatUserTokenClient(UserTokenClient utc) : Microsoft.Bot.Connector.Authentication.UserTokenClient +{ + /// + /// Gets the status of all tokens for a specific user across all configured OAuth connections. + /// + /// The unique identifier of the user. Cannot be null or empty. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// Optional filter to limit which token statuses are returned. Pass null or empty to include all. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an array of + /// objects representing the status of each configured connection for the user. + /// + 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(); + } + + /// + /// Retrieves an OAuth token for a user from a specific connection. + /// + /// The unique identifier of the user requesting the token. Cannot be null or empty. + /// The name of the OAuth connection configured in Azure Bot Service. Cannot be null or empty. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// Optional magic code from the OAuth callback. Used to complete the OAuth flow when provided. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a with + /// the OAuth token if available, or null if the user has not completed authentication for this connection. + /// + 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 + }; + } + + /// + /// Retrieves the sign-in resource (URL and exchange resources) needed to initiate an OAuth flow for a user. + /// + /// The name of the OAuth connection configured in Azure Bot Service. Cannot be null or empty. + /// The activity associated with the sign-in request. Used to extract user and channel information. Cannot be null. + /// Optional URL to redirect the user to after completing authentication. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the sign-in link and optional token exchange or post resources for completing the OAuth flow. + /// + /// Thrown when is null. + 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; + } + + /// + /// Exchanges a token from one OAuth connection for a token from another connection using single sign-on (SSO). + /// + /// The unique identifier of the user whose token is being exchanged. Cannot be null or empty. + /// The name of the target OAuth connection to exchange to. Cannot be null or empty. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// The token exchange request containing the source token. Cannot be null. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the exchanged token for the target connection. + /// + 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 + }; + } + + /// + /// Signs out a user from a specific OAuth connection, revoking their stored token. + /// + /// The unique identifier of the user to sign out. Cannot be null or empty. + /// The name of the OAuth connection to sign out from. Cannot be null or empty. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous sign-out operation. + public async override Task SignOutUserAsync(string userId, string connectionName, string channelId, CancellationToken cancellationToken) + { + await utc.SignOutUserAsync(userId, connectionName, channelId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves Azure Active Directory (Azure AD) tokens for multiple resource URLs in a single request. + /// + /// The unique identifier of the user requesting the tokens. Cannot be null or empty. + /// The name of the OAuth connection configured for Azure AD. Cannot be null or empty. + /// An array of resource URLs (e.g., "https://graph.microsoft.com") to request tokens for. Cannot be null. + /// The channel identifier where the user is interacting. Cannot be null or empty. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a dictionary mapping each + /// resource URL to its corresponding . Returns an empty dictionary if no tokens are available. + /// + 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.Apps.BotBuilder/InternalsVisibleTo.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/InternalsVisibleTo.cs new file mode 100644 index 000000000..23c6fdcfd --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/InternalsVisibleTo.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Teams.Core.Tests")] +[assembly: InternalsVisibleTo("Microsoft.Teams.Apps.BotBuilder.UnitTests")] +[assembly: InternalsVisibleTo("IntegrationTests")] diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/Log.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/Log.cs new file mode 100644 index 000000000..debd3fc35 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/Log.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Apps.BotBuilder; + +/// +/// High-performance logging methods generated via the source generator. +/// +internal static partial class Log +{ + // ── TeamsBotAdapter ───────────────────────────────────────────────── + + [LoggerMessage(EventId = 110, Level = LogLevel.Debug, Message = "Resp from SendActivitiesAsync: {RespId}")] + public static partial void SendActivitiesResponse(this ILogger logger, string? respId); + + [LoggerMessage(EventId = 111, Level = LogLevel.Trace, Message = "Sending Invoke Response: \n {InvokeResponse} with status: {Status} \n")] + public static partial void SendingInvokeResponse(this ILogger logger, string invokeResponse, int status); + + [LoggerMessage(EventId = 112, Level = LogLevel.Warning, Message = "HTTP response is null or has started. Cannot write invoke response. ResponseStarted: {ResponseStarted}")] + public static partial void CannotWriteInvokeResponse(this ILogger logger, bool? responseStarted); + + // ── TeamsBotFrameworkHttpAdapter ───────────────────────────────────── + + [LoggerMessage(EventId = 120, Level = LogLevel.Error, Message = "Error processing activity: Id={Id}. Delegating to OnTurnError.")] + public static partial void ActivityProcessingErrorDelegating(this ILogger logger, Exception ex, string? id); +} diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/Microsoft.Teams.Apps.BotBuilder.csproj b/core/src/Microsoft.Teams.Apps.BotBuilder/Microsoft.Teams.Apps.BotBuilder.csproj new file mode 100644 index 000000000..5ad0499a8 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/Microsoft.Teams.Apps.BotBuilder.csproj @@ -0,0 +1,14 @@ + + + + net8.0;net10.0 + enable + enable + + + + + + + + diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/README.md b/core/src/Microsoft.Teams.Apps.BotBuilder/README.md new file mode 100644 index 000000000..5ec4a5c33 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/README.md @@ -0,0 +1,146 @@ + + + +# Microsoft.Teams.Apps.BotBuilder + +A compatibility bridge that enables existing **Bot Framework SDK v4** applications to run on the modern **Microsoft Teams Bot Core** infrastructure. It implements the Adapter pattern, translating between Bot Framework interfaces and the new Teams Core SDK — allowing migration without rewriting existing bot logic. + +## Key Features + +- **Drop-in Adapter** — `TeamsBotFrameworkHttpAdapter` implements `IBotFrameworkHttpAdapter`, so existing bots work with minimal changes +- **Full Conversation Support** — Send, update, delete activities, manage members, and handle attachments through adapted interfaces +- **OAuth Compatibility** — `CompatUserTokenClient` bridges token management between the two frameworks +- **Proactive Messaging** — `ContinueConversationAsync` for resuming conversations from external triggers +- **Teams API Access** — Static `TeamsApiClient` methods for Teams-specific operations (meetings, batch messaging, team/channel metadata) +- **Schema Translation** — Bidirectional conversion between Bot Framework and Core activity models + +## Installation + +```shell +dotnet add package Microsoft.Teams.Apps.BotBuilder +``` + +## Quick Start + +### Register the Adapter + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.AddTeamsBotFrameworkHttpAdapter(); + +var app = builder.Build(); +// Map your existing IBot implementation to the endpoint +app.MapPost("api/messages", async (HttpContext context) => +{ + var adapter = context.RequestServices + .GetRequiredService(); + var bot = context.RequestServices.GetRequiredService(); + await adapter.ProcessAsync( + context.Request, context.Response, bot, context.RequestAborted); +}); + +app.Run(); +``` + +### Use with an Existing Bot + +Your existing `IBot` implementation works unchanged: + +```csharp +public class MyBot : ActivityHandler +{ + protected override async Task OnMessageActivityAsync( + ITurnContext turnContext, CancellationToken ct) + { + await turnContext.SendActivityAsync( + MessageFactory.Text($"Echo: {turnContext.Activity.Text}"), ct); + } +} + +// Register your bot +builder.Services.AddTransient(); +``` + +### Teams-Specific Operations + +Use the static `TeamsApiClient` for Teams APIs within your bot handlers: + +```csharp +// Get a specific member +var member = await TeamsApiClient.GetMemberAsync(turnContext, userId); + +// Get team details +var team = await TeamsApiClient.GetTeamDetailsAsync(turnContext); + +// Get paginated team members +var members = await TeamsApiClient.GetPagedTeamMembersAsync(turnContext, teamId); + +// Meeting info +var meeting = await TeamsApiClient.GetMeetingInfoAsync(turnContext); + +// Send notification to a meeting +await TeamsApiClient.SendMeetingNotificationAsync(turnContext, notification); +``` + +### Batch Messaging + +```csharp +// Send to all users in a team +var operationId = await TeamsApiClient.SendMessageToAllUsersInTeamAsync( + turnContext, activity, teamId, tenantId); + +// Send to all users in tenant +var operationId = await TeamsApiClient.SendMessageToAllUsersInTenantAsync( + turnContext, activity, tenantId); + +// Check operation status +var state = await TeamsApiClient.GetOperationStateAsync(turnContext, operationId); + +// Get failed entries +var failures = await TeamsApiClient.GetPagedFailedEntriesAsync(turnContext, operationId); +``` + +### Proactive Messaging + +```csharp +var adapter = serviceProvider + .GetRequiredService() as TeamsBotFrameworkHttpAdapter; + +await adapter!.ContinueConversationAsync( + botId, conversationReference, + async (turnContext, ct) => + { + await turnContext.SendActivityAsync("Proactive notification!", cancellationToken: ct); + }); +``` + +## Architecture + +The library bridges two frameworks through a set of adapter classes: + +``` +Bot Framework SDK Teams Bot Core +───────────────── ────────────── +IBotFrameworkHttpAdapter ←──→ TeamsBotFrameworkHttpAdapter +BotAdapter ←──→ TeamsBotAdapter +IConnectorClient ←──→ CompatConnectorClient +IConversations ←──→ CompatConversations → ConversationClient +UserTokenClient ←──→ CompatUserTokenClient → Core UserTokenClient +Activity (BF) ←──→ CoreActivity (ActivitySchemaMapper) +``` + +- **`TeamsBotFrameworkHttpAdapter`** handles HTTP request/response lifecycle and delegates to the Core SDK +- **`CompatConversations`** implements `IConversations` by forwarding calls to the Core `ConversationClient` +- **`CompatUserTokenClient`** adapts Core token operations to the Bot Framework `UserTokenClient` interface +- **`ActivitySchemaMapper`** provides bidirectional conversion between Bot Framework `Activity` and Core `CoreActivity` +- **`TeamsApiClient`** provides static methods for Teams-specific APIs not covered by standard Bot Framework interfaces + +## Main Types + +| Type | Description | +|------|-------------| +| `TeamsBotFrameworkHttpAdapter` | Primary adapter — implements `IBotFrameworkHttpAdapter` with full HTTP lifecycle | +| `TeamsBotAdapter` | Base adapter bridging `BotAdapter` to Teams Core activity processing | +| `TeamsApiClient` | Static utility for Teams-specific APIs (members, meetings, batch messaging, channels) | +| `ActivitySchemaMapper` | Bidirectional conversion between Bot Framework and Core activity schemas | +| `CompatHostingExtensions` | DI registration via `AddTeamsBotFrameworkHttpAdapter()` | diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsApiClient.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsApiClient.cs new file mode 100644 index 000000000..4c9b33f2c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsApiClient.cs @@ -0,0 +1,775 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; +using Newtonsoft.Json; +using static Microsoft.Teams.Apps.BotBuilder.TeamsApiClientModels; +using BotFrameworkTeams = Microsoft.Bot.Schema.Teams; +using CustomHeaders = System.Collections.Generic.Dictionary; + +namespace Microsoft.Teams.Apps.BotBuilder; + +/// +/// Provides utility methods for the events and interactions that occur within Microsoft Teams. +/// This class adapts the Teams Bot Core SDK to the Bot Framework v4 SDK TeamsInfo API. +/// +public static class TeamsApiClient +{ + internal static CustomHeaders DefaultCustomHeaders { get; } = []; + + #region Helper Methods + + + private static ConversationClient GetConversationClient(ITurnContext turnContext) + { + IConnectorClient connectorClient = turnContext.TurnState.Get() + ?? throw new InvalidOperationException("This method requires a connector client."); + + if (connectorClient is CompatConnectorClient compatClient) + { + return ((CompatConversations)compatClient.Conversations)._client; + } + + throw new InvalidOperationException("Connector client is not compatible."); + } + + private static string GetServiceUrl(ITurnContext turnContext) + { + return turnContext.Activity.ServiceUrl + ?? throw new InvalidOperationException("ServiceUrl is required."); + } + + private static AgenticIdentity GetIdentity(ITurnContext turnContext) + { + CoreActivity coreActivity = turnContext.Activity.FromBotFrameworkActivity(); + return AgenticIdentity.FromAccount(coreActivity.From) ?? new AgenticIdentity(); + } + + #endregion + + #region Member & Participant Methods + + /// + /// Gets the account of a single conversation member. + /// This works in one-on-one, group, and teams scoped conversations. + /// + /// Turn context. + /// ID of the user in question. + /// Cancellation token. + /// The member's channel account information. + public static async Task GetMemberAsync( + ITurnContext turnContext, + string userId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + TeamInfo? teamInfo = turnContext.Activity.TeamsGetTeamInfo(); + + if (teamInfo?.Id != null) + { + return await GetTeamMemberAsync(turnContext, userId, teamInfo.Id, cancellationToken).ConfigureAwait(false); + } + else + { + string conversationId = turnContext.Activity?.Conversation?.Id + ?? throw new InvalidOperationException("The GetMember operation needs a valid conversation Id."); + + if (userId == null) + { + throw new InvalidOperationException("The GetMember operation needs a valid user Id."); + } + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + Core.Schema.ConversationAccount result = await client.GetConversationMemberAsync( + conversationId, userId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatTeamsChannelAccount(); + } + } + + /// + /// Gets the conversation members of a one-on-one or group chat. + /// + /// Turn context. + /// Cancellation token. + /// List of channel accounts. + [Obsolete("Microsoft Teams is deprecating the non-paged version of the getMembers API which this method uses. Please use GetPagedMembersAsync instead of this API.")] + public static async Task> GetMembersAsync( + ITurnContext turnContext, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + TeamInfo? teamInfo = turnContext.Activity.TeamsGetTeamInfo(); + + if (teamInfo?.Id != null) + { + return await GetTeamMembersAsync(turnContext, teamInfo.Id, cancellationToken).ConfigureAwait(false); + } + else + { + string conversationId = turnContext.Activity?.Conversation?.Id + ?? throw new InvalidOperationException("The GetMembers operation needs a valid conversation Id."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + IList members = await client.GetConversationMembersAsync( + conversationId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return members.Select(m => m.ToCompatTeamsChannelAccount()); + } + } + + /// + /// Gets a paginated list of members of one-on-one, group, or team conversation. + /// + /// Turn context. + /// Suggested number of entries on a page. + /// Continuation token. + /// Cancellation token. + /// Paged members result. + public static async Task GetPagedMembersAsync( + ITurnContext turnContext, + int? pageSize = default, + string? continuationToken = default, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + TeamInfo? teamInfo = turnContext.Activity.TeamsGetTeamInfo(); + + if (teamInfo?.Id != null) + { + return await GetPagedTeamMembersAsync(turnContext, teamInfo.Id, continuationToken, pageSize, cancellationToken).ConfigureAwait(false); + } + else + { + string conversationId = turnContext.Activity?.Conversation?.Id + ?? throw new InvalidOperationException("The GetMembers operation needs a valid conversation Id."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + Core.PagedMembersResult pagedMembers = await client.GetConversationPagedMembersAsync( + conversationId, serviceUrl, pageSize, continuationToken, identity, null, cancellationToken).ConfigureAwait(false); + + return pagedMembers.ToCompatTeamsPagedMembersResult(); + } + } + + /// + /// Gets the member of a teams scoped conversation. + /// + /// Turn context. + /// User id. + /// ID of the Teams team. + /// Cancellation token. + /// Team member's channel account. + public static async Task GetTeamMemberAsync( + ITurnContext turnContext, + string userId, + string? teamId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + if (userId == null) + { + throw new InvalidOperationException("The GetMember operation needs a valid user Id."); + } + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + Core.Schema.ConversationAccount result = await client.GetConversationMemberAsync( + t, userId, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return result.ToCompatTeamsChannelAccount(); + } + + /// + /// Gets the list of BotFrameworkTeams.TeamsChannelAccounts within a team. + /// This only works in teams scoped conversations. + /// + /// Turn context. + /// ID of the Teams team. + /// Cancellation token. + /// List of team members. + [Obsolete("Microsoft Teams is deprecating the non-paged version of the getMembers API which this method uses. Please use GetPagedTeamMembersAsync instead of this API.")] + public static async Task> GetTeamMembersAsync( + ITurnContext turnContext, + string? teamId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + IList members = await client.GetConversationMembersAsync( + t, serviceUrl, identity, null, cancellationToken).ConfigureAwait(false); + + return members.Select(m => m.ToCompatTeamsChannelAccount()); + } + + /// + /// Gets a paginated list of members of a team. + /// This only works in teams scoped conversations. + /// + /// Turn context. + /// ID of the Teams team. + /// Continuation token. + /// Number of entries on the page. + /// Cancellation token. + /// Paged team members result. + public static async Task GetPagedTeamMembersAsync( + ITurnContext turnContext, + string? teamId = null, + string? continuationToken = default, + int? pageSize = default, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + Core.PagedMembersResult pagedMembers = await client.GetConversationPagedMembersAsync( + t, serviceUrl, pageSize, continuationToken, identity, null, cancellationToken).ConfigureAwait(false); + + return pagedMembers.ToCompatTeamsPagedMembersResult(); + } + + #endregion + + #region Meeting Methods + + /// + /// Gets the information for the given meeting id. + /// + /// Turn context. + /// The BASE64-encoded id of the Teams meeting. + /// Cancellation token. + /// Meeting information. + public static async Task GetMeetingInfoAsync( + ITurnContext turnContext, + string? meetingId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + ?? throw new InvalidOperationException("The meetingId can only be null if turnContext is within the scope of a MS Teams Meeting."); + + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + + ConversationClient client = GetConversationClient(turnContext); + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}"; + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching meeting info", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the details for the given meeting participant. This only works in teams meeting scoped conversations. + /// + /// Turn context. + /// The id of the Teams meeting. BotFrameworkTeams.TeamsChannelData.Meeting.Id will be used if none provided. + /// The id of the Teams meeting participant. From.AadObjectId will be used if none provided. + /// The id of the Teams meeting Tenant. BotFrameworkTeams.TeamsChannelData.Tenant.Id will be used if none provided. + /// Cancellation token. + /// Team participant channel account. + public static async Task GetMeetingParticipantAsync( + ITurnContext turnContext, + string? meetingId = null, + string? participantId = null, + string? tenantId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of a MS Teams Meeting."); + participantId ??= turnContext.Activity.From.AadObjectId + ?? throw new InvalidOperationException($"{nameof(participantId)} is required."); + tenantId ??= turnContext.Activity.GetChannelData()?.Tenant?.Id + ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}/participants/{Uri.EscapeDataString(participantId)}?tenantId={Uri.EscapeDataString(tenantId)}"; + + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "fetching meeting participant", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a notification to meeting participants. This functionality is available only in teams meeting scoped conversations. + /// + /// Turn context. + /// The notification to send to Teams. + /// The id of the Teams meeting. BotFrameworkTeams.TeamsChannelData.Meeting.Id will be used if none provided. + /// Cancellation token. + /// Meeting notification response. + public static async Task SendMeetingNotificationAsync( + ITurnContext turnContext, + BotFrameworkTeams.MeetingNotificationBase? notification, + string? meetingId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + meetingId ??= turnContext.Activity.TeamsGetMeetingInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of a MS Teams Meeting."); + notification = notification ?? throw new InvalidOperationException($"{nameof(notification)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v1/meetings/{Uri.EscapeDataString(meetingId)}/notification"; + string body = JsonConvert.SerializeObject(notification); + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending meeting notification", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + #endregion + + #region Team & Channel Methods + + /// + /// Gets the details for the given team id. This only works in teams scoped conversations. + /// + /// Turn context. + /// The id of the Teams team. + /// Cancellation token. + /// Team details. + public static async Task GetTeamDetailsAsync( + ITurnContext turnContext, + string? teamId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/teams/{Uri.EscapeDataString(t)}"; + + ConversationClient cc = GetConversationClient(turnContext); + + return (await cc.BotHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + new BotRequestOptions { AgenticIdentity = identity }, + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Returns a list of channels in a Team. + /// This only works in teams scoped conversations. + /// + /// Turn context. + /// ID of the Teams team. + /// Cancellation token. + /// List of channel information. + public static async Task GetTeamChannelsAsync( + ITurnContext turnContext, + string? teamId = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + string t = teamId ?? turnContext.Activity.TeamsGetTeamInfo()?.Id + ?? throw new InvalidOperationException("This method is only valid within the scope of MS Teams Team."); + + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity identity = GetIdentity(turnContext); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/teams/{Uri.EscapeDataString(t)}/conversations"; + + ConversationClient client = GetConversationClient(turnContext); + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + new BotRequestOptions { AgenticIdentity = identity }, + cancellationToken).ConfigureAwait(false))!; + } + + #endregion + + + #region Batch Messaging Methods + + /// + /// Sends a message to the provided list of Teams members. + /// + /// Turn context. + /// The activity to send. + /// The list of members. + /// The tenant ID. + /// Cancellation token. + /// The operation Id. + public static async Task SendMessageToListOfUsersAsync( + ITurnContext turnContext, + IActivity activity, + IList teamsMembers, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + teamsMembers = teamsMembers ?? throw new InvalidOperationException($"{nameof(teamsMembers)} is required."); + tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/users/"; + SendMessageToUsersRequest request = new() + { + Members = teamsMembers, + Activity = activity, + TenantId = tenantId + }; + string body = JsonConvert.SerializeObject(request); + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to list of users", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to the provided list of Teams channels. + /// + /// Turn context. + /// The activity to send. + /// The list of channels. + /// The tenant ID. + /// Cancellation token. + /// The operation Id. + public static async Task SendMessageToListOfChannelsAsync( + ITurnContext turnContext, + IActivity activity, + IList channelsMembers, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + channelsMembers = channelsMembers ?? throw new InvalidOperationException($"{nameof(channelsMembers)} is required."); + tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/channels/"; + SendMessageToUsersRequest request = new() + { + Members = channelsMembers, + Activity = activity, + TenantId = tenantId + }; + string body = JsonConvert.SerializeObject(request); + + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to list of channels", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to all the users in a team. + /// + /// The turn context. + /// The activity to send to the users in the team. + /// The team ID. + /// The tenant ID. + /// Cancellation token. + /// The operation Id. + public static async Task SendMessageToAllUsersInTeamAsync( + ITurnContext turnContext, + IActivity activity, + string teamId, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + teamId = teamId ?? throw new InvalidOperationException($"{nameof(teamId)} is required."); + tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + if (activity is not Activity teamActivity) + throw new ArgumentException("Expected a Bot Framework Activity instance.", nameof(activity)); + CoreActivity coreActivity = teamActivity.FromBotFrameworkActivity(); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/team/"; + SendMessageToTeamRequest request = new() + { + Activity = activity, + TeamId = teamId, + TenantId = tenantId + }; + string body = JsonConvert.SerializeObject(request); + + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to all users in team", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Sends a message to all the users in a tenant. + /// + /// The turn context. + /// The activity to send to the tenant. + /// The tenant ID. + /// Cancellation token. + /// The operation Id. + public static async Task SendMessageToAllUsersInTenantAsync( + ITurnContext turnContext, + IActivity activity, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + activity = activity ?? throw new InvalidOperationException($"{nameof(activity)} is required."); + tenantId = tenantId ?? throw new InvalidOperationException($"{nameof(tenantId)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + if (activity is not Activity tenantActivity) + throw new ArgumentException("Expected a Bot Framework Activity instance.", nameof(activity)); + CoreActivity coreActivity = tenantActivity.FromBotFrameworkActivity(); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/tenant/"; + SendMessageToTenantRequest request = new() + { + Activity = activity, + TenantId = tenantId + }; + string body = JsonConvert.SerializeObject(request); + + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending message to all users in tenant", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Creates a new thread in a team chat and sends an activity to that new thread. + /// Use this method if you are using CloudAdapter where credentials are handled by the adapter. + /// + /// Turn context. + /// The activity to send on starting the new thread. + /// The Team's Channel ID, note this is distinct from the Bot Framework activity property with same name. + /// The bot's appId. + /// Cancellation token. + /// Tuple with conversation reference and activity id. + public static async Task> SendMessageToTeamsChannelAsync( + ITurnContext turnContext, + IActivity activity, + string teamsChannelId, + string botAppId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + + if (turnContext.Activity == null) + { + throw new InvalidOperationException(nameof(turnContext.Activity)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(teamsChannelId); + + ConversationReference? conversationReference = null; + string newActivityId = string.Empty; + string serviceUrl = turnContext.Activity.ServiceUrl; + Microsoft.Bot.Schema.ConversationParameters conversationParameters = new() + { + IsGroup = true, + ChannelData = new BotFrameworkTeams.TeamsChannelData { Channel = new BotFrameworkTeams.ChannelInfo { Id = teamsChannelId } }, + Activity = activity as Activity ?? throw new ArgumentException("Expected a Bot Framework Activity instance.", nameof(activity)), + }; + + await turnContext.Adapter.CreateConversationAsync( + botAppId, + Channels.Msteams, + serviceUrl, + null, + conversationParameters, + (t, ct) => + { + conversationReference = t.Activity.GetConversationReference(); + newActivityId = t.Activity.Id; + return Task.CompletedTask; + }, + cancellationToken).ConfigureAwait(false); + + return new Tuple(conversationReference!, newActivityId); + } + + #endregion + + #region Batch Operation Management + + /// + /// Gets the state of an operation. + /// + /// Turn context. + /// The operationId to get the state of. + /// Cancellation token. + /// The state and responses of the operation. + public static async Task GetOperationStateAsync( + ITurnContext turnContext, + string operationId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/{Uri.EscapeDataString(operationId)}"; + + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting operation state", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Gets the failed entries of a batch operation. + /// + /// The turn context. + /// The operationId to get the failed entries of. + /// The continuation token. + /// Cancellation token. + /// The list of failed entries of the operation. + public static async Task GetPagedFailedEntriesAsync( + ITurnContext turnContext, + string operationId, + string? continuationToken = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/failedentries/{Uri.EscapeDataString(operationId)}"; + + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + url += $"?continuationToken={Uri.EscapeDataString(continuationToken)}"; + } + + return (await client.BotHttpClient.SendAsync( + HttpMethod.Get, + url, + body: null, + CreateRequestOptions(agenticIdentity, "getting paged failed entries", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Cancels a batch operation by its id. + /// + /// The turn context. + /// The id of the operation to cancel. + /// Cancellation token. + /// A task representing the asynchronous operation. + public static async Task CancelOperationAsync( + ITurnContext turnContext, + string operationId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + operationId = operationId ?? throw new InvalidOperationException($"{nameof(operationId)} is required."); + + ConversationClient client = GetConversationClient(turnContext); + Uri serviceUrl = new(GetServiceUrl(turnContext)); + AgenticIdentity agenticIdentity = GetIdentity(turnContext); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/batch/conversation/{Uri.EscapeDataString(operationId)}"; + + await client.BotHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "cancelling operation", DefaultCustomHeaders), + cancellationToken).ConfigureAwait(false); + } + + #endregion + + + private static BotRequestOptions CreateRequestOptions(AgenticIdentity? agenticIdentity, string operationDescription, CustomHeaders? customHeaders) => + new() + { + AgenticIdentity = agenticIdentity, + OperationDescription = operationDescription, + DefaultHeaders = DefaultCustomHeaders, + CustomHeaders = customHeaders + }; +} diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsBotAdapter.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsBotAdapter.cs new file mode 100644 index 000000000..de41d5b71 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsBotAdapter.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Newtonsoft.Json; + + +namespace Microsoft.Teams.Apps.BotBuilder; + +/// +/// 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 Teams bot application instance. +/// The HTTP context accessor. +/// The logger instance. +public class TeamsBotAdapter( + BotApplication botApplication, + IHttpContextAccessor? httpContextAccessor = null, + ILogger? logger = null) : BotAdapter +{ + private readonly JsonSerializerOptions _writeIndentedJsonOptions = new() { WriteIndented = true }; + private readonly BotApplication botApplication = botApplication; + private readonly IHttpContextAccessor? httpContextAccessor = httpContextAccessor; + private readonly ILogger? logger = logger; + + /// + /// Deletes an activity from the conversation. + /// + /// The turn context containing the activity information. Cannot be null. + /// The conversation reference identifying the activity to delete. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous delete operation. + public override async Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(turnContext); + ArgumentNullException.ThrowIfNull(reference); + + // Extract values from conversation reference + string conversationId = reference.Conversation?.Id + ?? throw new ArgumentException("ConversationReference must contain a valid Conversation.Id", nameof(reference)); + + string activityId = reference.ActivityId + ?? throw new ArgumentException("ConversationReference must contain a valid ActivityId", nameof(reference)); + + string serviceUrlString = reference.ServiceUrl + ?? turnContext.Activity.ServiceUrl + ?? throw new ArgumentException("ServiceUrl must be provided", nameof(reference)); + + Uri serviceUrl = new(serviceUrlString); + + // Extract agentic identity from turn context if available + AgenticIdentity? agenticIdentity = AgenticIdentity.FromAccount(turnContext.Activity?.FromBotFrameworkActivity().From); + + await botApplication.ConversationClient.DeleteActivityAsync( + conversationId, + activityId, + serviceUrl, + agenticIdentity, + customHeaders: null, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends a set of activities to the conversation. + /// + /// The turn context for the conversation. Cannot be null. + /// An array of activities to send. Cannot be null. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains an array of + /// objects with the IDs of the sent activities. + /// + 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 }]; + } + + CoreActivity coreActivity = activity.FromBotFrameworkActivity(); + + // Ensure ServiceUrl is set from turn context if not already present + if (coreActivity.ServiceUrl == null && !string.IsNullOrWhiteSpace(turnContext.Activity.ServiceUrl)) + { + coreActivity.ServiceUrl = new Uri(turnContext.Activity.ServiceUrl); + } + + coreActivity.Conversation ??= new Microsoft.Teams.Core.Schema.Conversation( + turnContext.Activity.Conversation?.Id + ?? throw new InvalidOperationException("Conversation ID is required to send activities.")); + SendActivityResponse? resp = await botApplication.SendActivityAsync(coreActivity, cancellationToken: cancellationToken).ConfigureAwait(false); + + logger?.SendActivitiesResponse(resp?.Id); + + responses[i] = new Microsoft.Bot.Schema.ResourceResponse() { Id = resp?.Id }; + } + return responses; + } + + /// + /// Updates an existing activity in the conversation. + /// + /// The turn context for the conversation. + /// The activity with updated content. Cannot be null. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the ID of the updated activity. + /// + public override async Task UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(turnContext); + + CoreActivity coreActivity = activity.FromBotFrameworkActivity(); + + // Ensure ServiceUrl is set from turn context if not already present + if (coreActivity.ServiceUrl == null && !string.IsNullOrWhiteSpace(turnContext.Activity.ServiceUrl)) + { + coreActivity.ServiceUrl = new Uri(turnContext.Activity.ServiceUrl); + } + + UpdateActivityResponse res = await botApplication.ConversationClient.UpdateActivityAsync( + activity.Conversation.Id, + activity.Id, + coreActivity, + 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) + { + response.StatusCode = invokeResponse.Status; + using StreamWriter httpResponseStreamWriter = new(response.BodyWriter.AsStream()); + using JsonTextWriter httpResponseJsonWriter = new(httpResponseStreamWriter); + if (logger?.IsEnabled(LogLevel.Trace) == true) + { + logger.SendingInvokeResponse(System.Text.Json.JsonSerializer.Serialize(invokeResponse.Body, _writeIndentedJsonOptions), invokeResponse.Status); + } + if (invokeResponse.Body is not null) + { + Microsoft.Bot.Builder.Integration.AspNet.Core.HttpHelper.BotMessageSerializer.Serialize(httpResponseJsonWriter, invokeResponse.Body); + } + } + else + { + logger?.CannotWriteInvokeResponse(response?.HasStarted); + } + } +} diff --git a/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsBotFrameworkHttpAdapter.cs b/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsBotFrameworkHttpAdapter.cs new file mode 100644 index 000000000..a887fc69f --- /dev/null +++ b/core/src/Microsoft.Teams.Apps.BotBuilder/TeamsBotFrameworkHttpAdapter.cs @@ -0,0 +1,133 @@ +// 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.Bot.Schema; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; + + +namespace Microsoft.Teams.Apps.BotBuilder; + +/// +/// 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. +public class TeamsBotFrameworkHttpAdapter : TeamsBotAdapter, IBotFrameworkHttpAdapter +{ + private static readonly AsyncLocal?> _activityCallback = new(); + private readonly BotApplication _teamsBotApplication; + private readonly ILogger? _logger; + + /// + /// Creates a new instance of the class. + /// + /// The Teams bot application instance. + /// The HTTP context accessor. + /// The logger instance. + public TeamsBotFrameworkHttpAdapter( + BotApplication teamsBotApplication, + IHttpContextAccessor? httpContextAccessor = null, + ILogger? logger = null) + : base(teamsBotApplication, httpContextAccessor, logger) + { + _teamsBotApplication = teamsBotApplication; + _logger = logger; + + // Set the OnActivity handler once to a dispatcher that delegates to the + // AsyncLocal callback, isolating each concurrent request's handler. + _teamsBotApplication.OnActivity = (activity, ct) => + _activityCallback.Value?.Invoke(activity, ct) ?? Task.CompletedTask; + } + + /// + /// Processes an incoming HTTP request and generates an appropriate HTTP response using the provided bot instance. + /// + /// The incoming HTTP request containing the bot activity. Cannot be null. + /// The HTTP response to write results to. Cannot be null. + /// The bot instance that will process the activity. Cannot be null. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous processing operation. + 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; + _activityCallback.Value = async (activity, ct) => + { + coreActivity = activity; + TurnContext turnContext = new(this, activity.ToBotFrameworkActivity()); + turnContext.TurnState.Add(new CompatUserTokenClient(_teamsBotApplication.UserTokenClient)); + CompatConnectorClient connectionClient = new(new CompatConversations(_teamsBotApplication.ConversationClient) + { + ServiceUrl = activity.ServiceUrl?.ToString(), + AgenticIdentity = activity.From?.GetAgenticIdentity() + }); + turnContext.TurnState.Add(connectionClient); + //turnContext.TurnState.Add(_teamsBotApplication.TeamsApiClient); // TODO: review TeamsInfo needs + await MiddlewareSet.ReceiveActivityWithStatusAsync(turnContext, bot.OnTurnAsync, ct).ConfigureAwait(false); + }; + + try + { + await _teamsBotApplication.ProcessAsync(httpRequest.HttpContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (OnTurnError != null) + { + if (ex is BotHandlerException aex) + { + _logger?.ActivityProcessingErrorDelegating(ex, aex.Activity?.Id); + coreActivity = aex.Activity; + using TurnContext turnContext = new(this, coreActivity!.ToBotFrameworkActivity()); + await OnTurnError(turnContext, ex).ConfigureAwait(false); + } + else + { + throw; + } + } + else + { + throw; + } + } + finally + { + _activityCallback.Value = null; + } + } + + /// + /// 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 override Task ContinueConversationAsync(string botId, ConversationReference reference, BotCallbackHandler callback, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(callback); + + using TurnContext turnContext = new(this, reference.GetContinuationActivity()); + turnContext.TurnState.Add(new CompatUserTokenClient(_teamsBotApplication.UserTokenClient)); + turnContext.TurnState.Add(new CompatConnectorClient(new CompatConversations(_teamsBotApplication.ConversationClient) { ServiceUrl = reference.ServiceUrl })); + await RunPipelineAsync(turnContext, callback, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ActivityClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ActivityClient.cs new file mode 100644 index 000000000..981555220 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ActivityClient.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; + +using CoreConversationClient = Microsoft.Teams.Core.ConversationClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for creating, updating, and deleting activities in a conversation. +/// Delegates to the core . +/// +public class ActivityClient +{ + private readonly CoreConversationClient _client; + private readonly Uri _serviceUrl; + + internal ActivityClient(Uri serviceUrl, CoreConversationClient client) + { + _serviceUrl = serviceUrl; + _client = client; + } + + /// + /// Create a new activity in a conversation. + /// + public Task CreateAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + activity.ServiceUrl ??= _serviceUrl; + activity.Conversation ??= new Conversation(conversationId); + return _client.SendActivityAsync(activity, cancellationToken: cancellationToken); + } + + /// + /// Update an existing activity in a conversation. + /// + public Task UpdateAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + activity.ServiceUrl ??= _serviceUrl; + AgenticIdentity? agenticIdentity = AgenticIdentity.FromAccount(activity.From); + return _client.UpdateActivityAsync(conversationId, id, activity, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + } + + /// + /// Reply to an existing activity in a conversation. + /// + public Task ReplyAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + activity.ReplyToId = id; + activity.ServiceUrl ??= _serviceUrl; + activity.Conversation ??= new Conversation(conversationId); + return _client.SendActivityAsync(activity, cancellationToken: cancellationToken); + } + + /// + /// Delete an activity from a conversation. + /// + public Task DeleteAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + return _client.DeleteActivityAsync(conversationId, id, _serviceUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + } + + /// + /// Create a new targeted activity in a conversation. + /// Targeted activities are only visible to the specified recipient. + /// + [Experimental("ExperimentalTeamsTargeted")] + public Task CreateTargetedAsync(string conversationId, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + activity.ServiceUrl ??= _serviceUrl; + activity.Conversation ??= new Conversation(conversationId); + // Ensure recipient is marked as targeted + if (activity.Recipient != null) + { + activity.Recipient.IsTargeted = true; + } + return _client.SendActivityAsync(activity, cancellationToken: cancellationToken); + } + + /// + /// Update an existing targeted activity in a conversation. + /// + [Experimental("ExperimentalTeamsTargeted")] + public Task UpdateTargetedAsync(string conversationId, string id, CoreActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + activity.ServiceUrl ??= _serviceUrl; + return _client.UpdateTargetedActivityAsync(conversationId, id, activity, agenticIdentity: activity.From?.GetAgenticIdentity(), cancellationToken: cancellationToken); + } + + /// + /// Delete a targeted activity from a conversation. + /// + public Task DeleteTargetedAsync(string conversationId, string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + return _client.DeleteTargetedActivityAsync(conversationId, id, _serviceUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ApiClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ApiClient.cs new file mode 100644 index 000000000..3dc4556d0 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ApiClient.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core.Http; + +using CoreConversationClient = Microsoft.Teams.Core.ConversationClient; +using CoreUserTokenClient = Microsoft.Teams.Core.UserTokenClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Top-level API client that provides access to all Teams Bot API sub-clients. +/// +/// +/// +/// This client can be constructed in two ways: +/// +/// +/// DI-friendly (no serviceUrl) — Use +/// and call per-request to create a scoped instance. +/// Fully initialized — Use +/// when the service URL is known upfront. +/// +/// +public class ApiClient +{ + private readonly BotHttpClient _http; + private readonly CoreConversationClient _conversationClient; + private readonly CoreUserTokenClient _userTokenClient; + + /// + /// The service URL used by this client. + /// Null when constructed without a service URL (DI-friendly constructor). + /// Call to create a scoped instance with a service URL. + /// + public virtual Uri ServiceUrl { get; } + + /// + /// Client for bot-level operations (token, sign-in). + /// + public virtual BotClient Bots { get; } + + /// + /// Client for conversation operations (activities, members, reactions). + /// + public virtual ConversationApiClient Conversations { get; } + + /// + /// Client for user-level operations (token). + /// + public virtual UserClient Users { get; } + + /// + /// Client for team operations. + /// + public virtual TeamClient Teams { get; } + + /// + /// Client for meeting operations. + /// + public virtual MeetingClient Meetings { get; } + + /// + /// Creates a new without a service URL (DI-friendly). + /// Use to create a scoped instance bound to a specific service URL. + /// + /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). + /// The core conversation client for conversation/activity/member operations. + /// The core user token client for sign-in and token operations. + /// Optional logger. + [ActivatorUtilitiesConstructor] + public ApiClient(HttpClient httpClient, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(conversationClient); + ArgumentNullException.ThrowIfNull(userTokenClient); + + _http = new BotHttpClient(httpClient, logger); + _conversationClient = conversationClient; + _userTokenClient = userTokenClient; + Bots = new BotClient(userTokenClient); + Users = new UserClient(userTokenClient); + + // ServiceUrl-dependent sub-clients require ForServiceUrl() before use + ServiceUrl = null!; + Conversations = null!; + Teams = null!; + Meetings = null!; + } + + /// + /// Creates a new bound to a specific service URL. + /// + /// The Bot Framework service URL. + /// An configured with authentication (e.g., via DI with BotAuthenticationHandler). + /// The core conversation client for conversation/activity/member operations. + /// The core user token client for sign-in and token operations. + /// Optional logger. + public ApiClient(Uri serviceUrl, HttpClient httpClient, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(serviceUrl); + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(conversationClient); + ArgumentNullException.ThrowIfNull(userTokenClient); + + _http = new BotHttpClient(httpClient, logger); + _conversationClient = conversationClient; + _userTokenClient = userTokenClient; + ServiceUrl = serviceUrl; + Bots = new BotClient(userTokenClient); + Conversations = new ConversationApiClient(serviceUrl, conversationClient); + Users = new UserClient(userTokenClient); + Teams = new TeamClient(serviceUrl.ToString(), _http); + Meetings = new MeetingClient(serviceUrl.ToString(), _http); + } + + /// + /// Creates a copy of an existing with the same configuration. + /// + public ApiClient(ApiClient client) + { + ArgumentNullException.ThrowIfNull(client); + + ServiceUrl = client.ServiceUrl; + _http = client._http; + _conversationClient = client._conversationClient; + _userTokenClient = client._userTokenClient; + Bots = client.Bots; + Conversations = client.Conversations; + Users = client.Users; + Teams = client.Teams; + Meetings = client.Meetings; + } + + // Private constructor for ForServiceUrl — shares BotHttpClient, ConversationClient, and UserTokenClient + private ApiClient(BotHttpClient http, CoreConversationClient conversationClient, CoreUserTokenClient userTokenClient, Uri serviceUrl) + { + _http = http; + _conversationClient = conversationClient; + _userTokenClient = userTokenClient; + ServiceUrl = serviceUrl; + Bots = new BotClient(userTokenClient); + Conversations = new ConversationApiClient(serviceUrl, conversationClient); + Users = new UserClient(userTokenClient); + Teams = new TeamClient(serviceUrl.ToString(), http); + Meetings = new MeetingClient(serviceUrl.ToString(), http); + } + + /// + /// Creates a new scoped to the specified service URL, + /// sharing the underlying HTTP client and authentication. + /// + /// The Bot Framework service URL for this scope. + /// A new bound to the given service URL. + public virtual ApiClient ForServiceUrl(Uri serviceUrl) + { + ArgumentNullException.ThrowIfNull(serviceUrl); + return new ApiClient(_http, _conversationClient, _userTokenClient, serviceUrl); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/BotClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/BotClient.cs new file mode 100644 index 000000000..2732fd0ac --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/BotClient.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using CoreUserTokenClient = Microsoft.Teams.Core.UserTokenClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for bot-level operations, including the sign-in sub-client. +/// +public class BotClient +{ + /// + /// Client for bot sign-in operations. + /// + public BotSignInClient SignIn { get; } + + internal BotClient(CoreUserTokenClient userTokenClient) + { + SignIn = new BotSignInClient(userTokenClient); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/BotSignInClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/BotSignInClient.cs new file mode 100644 index 000000000..7fefabed7 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/BotSignInClient.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core; + +using CoreUserTokenClient = Microsoft.Teams.Core.UserTokenClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for bot sign-in operations. +/// Delegates to the core . +/// +public class BotSignInClient +{ + private readonly CoreUserTokenClient _client; + + internal BotSignInClient(CoreUserTokenClient client) + { + _client = client; + } + + /// + /// Get the sign-in URL for a connection. + /// + public Task GetUrlAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + { + return _client.GetSignInUrlAsync(state, codeChallenge, emulatorUrl, finalRedirect, cancellationToken); + } + + /// + /// Get the sign-in resource for a connection. + /// + public Task GetResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + { + return _client.GetSignInResourceAsync(state, codeChallenge, emulatorUrl, finalRedirect, cancellationToken)!; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/BotTokenClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/BotTokenClient.cs new file mode 100644 index 000000000..cd4561143 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/BotTokenClient.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for bot token operations. +/// +/// +/// In the core SDK, bot authentication is handled transparently by BotAuthenticationHandler, +/// which automatically acquires and attaches tokens to HTTP requests. This client exposes the +/// well-known token scopes for scenarios that need explicit scope references. +/// +public static class BotTokenClient +{ + /// + /// The default Bot Framework API scope. + /// + public static readonly string BotScope = "https://api.botframework.com/.default"; + + /// + /// The Microsoft Graph API scope. + /// + public static readonly string GraphScope = "https://graph.microsoft.com/.default"; +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs new file mode 100644 index 000000000..86e389978 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; + +using CoreConversationClient = Microsoft.Teams.Core.ConversationClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for managing conversations, exposing sub-clients for activities, members, and reactions. +/// Delegates to the core . +/// +public class ConversationApiClient +{ + private readonly CoreConversationClient _client; + private readonly Uri _serviceUrl; + + /// + /// Client for activity operations. + /// + public ActivityClient Activities { get; } + + /// + /// Client for member operations. + /// + public MemberClient Members { get; } + + /// + /// Client for reaction operations. + /// + [Experimental("ExperimentalTeamsReactions")] + public ReactionClient Reactions { get; } + + internal ConversationApiClient(Uri serviceUrl, CoreConversationClient client) + { + _serviceUrl = serviceUrl; + _client = client; + Activities = new ActivityClient(serviceUrl, client); + Members = new MemberClient(serviceUrl, client); +#pragma warning disable ExperimentalTeamsReactions // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + Reactions = new ReactionClient(serviceUrl, client); +#pragma warning restore ExperimentalTeamsReactions // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + } + + /// + /// Create a new conversation. + /// + public Task CreateAsync(ConversationParameters request, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + return _client.CreateConversationAsync(request, _serviceUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/MeetingClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/MeetingClient.cs new file mode 100644 index 000000000..c56801e43 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/MeetingClient.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for retrieving meeting information and participants. +/// +public class MeetingClient +{ + private readonly BotHttpClient _http; + private readonly string _serviceUrl; + + internal MeetingClient(string serviceUrl, BotHttpClient http) + { + _serviceUrl = serviceUrl.TrimEnd('/'); + _http = http; + } + + /// + /// Get a meeting by its ID. + /// + public async Task GetByIdAsync(string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v1/meetings/{Uri.EscapeDataString(id)}"; + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: CreateRequestOptions(agenticIdentity), cancellationToken).ConfigureAwait(false); + } + + /// + /// Get a participant in a meeting. + /// + public async Task GetParticipantAsync(string meetingId, string id, string tenantId, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v1/meetings/{Uri.EscapeDataString(meetingId)}/participants/{Uri.EscapeDataString(id)}?tenantId={Uri.EscapeDataString(tenantId)}"; + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: CreateRequestOptions(agenticIdentity), cancellationToken).ConfigureAwait(false); + } + + private static BotRequestOptions? CreateRequestOptions(AgenticIdentity? agenticIdentity) => + agenticIdentity is null ? null : new() { AgenticIdentity = agenticIdentity }; +} + +/// +/// General information about a Teams meeting. +/// +public class Meeting +{ + /// + /// Unique identifier representing a meeting. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The specific details of a Teams meeting. + /// + [JsonPropertyName("details")] + public MeetingDetails? Details { get; set; } + + /// + /// The conversation for the meeting. + /// + [JsonPropertyName("conversation")] + public Conversation? Conversation { get; set; } + + /// + /// The organizer's user information. + /// + [JsonPropertyName("organizer")] + public ConversationAccount? Organizer { get; set; } +} + +/// +/// The specific details of a Teams meeting. +/// +public class MeetingDetails +{ + /// + /// The meeting's Id, encoded as a BASE64 string. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The meeting's type. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The URL used to join the meeting. + /// + [JsonPropertyName("joinUrl")] + public Uri? JoinUrl { get; set; } + + /// + /// The title of the meeting. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } +} + +/// +/// Meeting participant information. +/// +public class MeetingParticipant +{ + /// + /// The participant's user information. + /// + [JsonPropertyName("user")] + public ConversationAccount? User { get; set; } + + /// + /// Information about the associated meeting. + /// + [JsonPropertyName("meeting")] + public MeetingInfo? Meeting { get; set; } + + /// + /// The conversation associated with this participant. + /// + [JsonPropertyName("conversation")] + public Conversation? Conversation { get; set; } +} + +/// +/// Represents information about a participant's role and status within a meeting. +/// +public class MeetingInfo +{ + /// + /// The role associated with the participant. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Whether the user is currently in a meeting. + /// + [JsonPropertyName("inMeeting")] + public bool? InMeeting { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/MemberClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/MemberClient.cs new file mode 100644 index 000000000..231f1ff75 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/MemberClient.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core.Schema; + +using CoreConversationClient = Microsoft.Teams.Core.ConversationClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for managing conversation members. +/// Delegates to the core . +/// +public class MemberClient +{ + private readonly CoreConversationClient _client; + private readonly Uri _serviceUrl; + + internal MemberClient(Uri serviceUrl, CoreConversationClient client) + { + _serviceUrl = serviceUrl; + _client = client; + } + + /// + /// Get all members of a conversation. + /// + public Task> GetAsync(string conversationId, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + return _client.GetConversationMembersAsync(conversationId, _serviceUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + } + + /// + /// Get a specific member of a conversation by ID. + /// + public Task GetByIdAsync(string conversationId, string memberId, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) where T : ConversationAccount + { + return _client.GetConversationMemberAsync(conversationId, memberId, _serviceUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + } + + /// + /// Get a specific member of a conversation by ID. + /// + public Task GetByIdAsync(string conversationId, string memberId, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + return GetByIdAsync(conversationId, memberId, agenticIdentity, cancellationToken); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ReactionClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ReactionClient.cs new file mode 100644 index 000000000..3ca471571 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ReactionClient.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Teams.Core.Schema; +using CoreConversationClient = Microsoft.Teams.Core.ConversationClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for managing reactions on activities in a conversation. +/// Delegates to the core . +/// +[Experimental("ExperimentalTeamsReactions")] +public class ReactionClient +{ + private readonly CoreConversationClient _client; + private readonly Uri _serviceUrl; + + internal ReactionClient(Uri serviceUrl, CoreConversationClient client) + { + _serviceUrl = serviceUrl; + _client = client; + } + + /// + /// Adds a reaction on an activity in a conversation. + /// + /// The conversation id. + /// The id of the activity to react to. + /// The reaction type (for example: "like", "heart", "laugh", etc.). + /// Optional agentic identity for authentication. + /// A to observe while waiting for the task to complete. + public Task AddAsync(string conversationId, string activityId, string reactionType, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + return _client.AddReactionAsync(conversationId, activityId, reactionType, _serviceUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + } + + /// + /// Removes a reaction from an activity in a conversation. + /// + /// The conversation id. + /// The id of the activity the reaction is on. + /// The reaction type to remove (for example: "like", "heart", "laugh", etc.). + /// Optional agentic identity for authentication. + /// A to observe while waiting for the task to complete. + public Task DeleteAsync(string conversationId, string activityId, string reactionType, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + return _client.DeleteReactionAsync(conversationId, activityId, reactionType, _serviceUrl, agenticIdentity: agenticIdentity, cancellationToken: cancellationToken); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/TeamClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/TeamClient.cs new file mode 100644 index 000000000..4ceb14f9c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/TeamClient.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for retrieving team information and channels. +/// +public class TeamClient +{ + private readonly BotHttpClient _http; + private readonly string _serviceUrl; + + internal TeamClient(string serviceUrl, BotHttpClient http) + { + _serviceUrl = serviceUrl.TrimEnd('/'); + _http = http; + } + + /// + /// Get a team by its ID. + /// + public async Task GetByIdAsync(string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/teams/{Uri.EscapeDataString(id)}"; + return await _http.SendAsync(HttpMethod.Get, url, body: null, options: CreateRequestOptions(agenticIdentity), cancellationToken).ConfigureAwait(false); + } + + /// + /// Get the channels (conversations) for a team. + /// + public async Task?> GetConversationsAsync(string id, AgenticIdentity? agenticIdentity = null, CancellationToken cancellationToken = default) + { + string url = $"{_serviceUrl}/v3/teams/{Uri.EscapeDataString(id)}/conversations"; + ConversationListResponse? response = await _http.SendAsync(HttpMethod.Get, url, body: null, options: CreateRequestOptions(agenticIdentity), cancellationToken).ConfigureAwait(false); + return response?.Conversations; + } + + private static BotRequestOptions? CreateRequestOptions(AgenticIdentity? agenticIdentity) => + agenticIdentity is null ? null : new() { AgenticIdentity = agenticIdentity }; + + private sealed class ConversationListResponse + { + [JsonPropertyName("conversations")] + public List? Conversations { get; set; } + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/UserClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/UserClient.cs new file mode 100644 index 000000000..fa5500cda --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/UserClient.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using CoreUserTokenClient = Microsoft.Teams.Core.UserTokenClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for user-level operations, including the token sub-client. +/// +public class UserClient +{ + /// + /// Client for user token operations. + /// + public UserTokenApiClient Token { get; } + + internal UserClient(CoreUserTokenClient userTokenClient) + { + Token = new UserTokenApiClient(userTokenClient); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/UserTokenApiClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/UserTokenApiClient.cs new file mode 100644 index 000000000..474c7e8fc --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/UserTokenApiClient.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core; + +using CoreUserTokenClient = Microsoft.Teams.Core.UserTokenClient; + +namespace Microsoft.Teams.Apps.Api.Clients; + +/// +/// Client for user token operations. +/// Delegates to the core . +/// +public class UserTokenApiClient +{ + private readonly CoreUserTokenClient _client; + + internal UserTokenApiClient(CoreUserTokenClient client) + { + _client = client; + } + + /// + /// Get a user token for a connection. + /// + public Task GetAsync(string userId, string connectionName, string channelId, string? code = null, CancellationToken cancellationToken = default) + { + return _client.GetTokenAsync(userId, connectionName, channelId, code, cancellationToken); + } + + /// + /// Get AAD tokens for specified resources. + /// + public async Task?> GetAadAsync(string userId, string connectionName, string channelId, IList? resourceUrls = null, CancellationToken cancellationToken = default) + { + return await _client.GetAadTokensAsync(userId, connectionName, channelId, resourceUrls?.ToArray(), cancellationToken).ConfigureAwait(false); + } + + /// + /// Get the token status for a user's connections. + /// + public async Task?> GetStatusAsync(string userId, string channelId, string? includeFilter = null, CancellationToken cancellationToken = default) + { + return await _client.GetTokenStatusAsync(userId, channelId, includeFilter, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sign a user out of a connection. + /// + public Task SignOutAsync(string userId, string connectionName, string channelId, CancellationToken cancellationToken = default) + { + return _client.SignOutUserAsync(userId, connectionName, channelId, cancellationToken); + } + + /// + /// Exchange a token for another token. + /// + public async Task ExchangeAsync(string userId, string connectionName, string channelId, string exchangeToken, CancellationToken cancellationToken = default) + { + return await _client.ExchangeTokenAsync(userId, connectionName, channelId, exchangeToken, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Apps/AppBuilder.cs b/core/src/Microsoft.Teams.Apps/AppBuilder.cs new file mode 100644 index 000000000..277652855 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/AppBuilder.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Apps; + +/// +/// Provides a static entry point for creating an . +/// +public static class App +{ + /// + /// Creates a new instance for configuring a Teams bot application. + /// + /// A new . + public static AppBuilder Builder() => new(); +} + +/// +/// Fluent builder for configuring a Teams bot application. +/// Wraps for backward compatibility with the old App.Builder() pattern. +/// +public class AppBuilder +{ + internal TeamsBotApplicationOptions Options { get; } = new(); + + /// + /// Registers an OAuth connection for the bot application. + /// + /// The OAuth connection name configured on the bot. + /// This builder instance for chaining. + public AppBuilder AddOAuth(string connectionName) + { + Options.AddOAuthFlow(connectionName); + return this; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Context.cs b/core/src/Microsoft.Teams.Apps/Context.cs new file mode 100644 index 000000000..d3e074340 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Context.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.OAuth; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core; + +namespace Microsoft.Teams.Apps; + + +/// +/// Context for a bot turn. +/// +/// The bot application instance that owns this context. +/// The incoming activity for this turn. +public class Context(TeamsBotApplication botApplication, TActivity activity) where TActivity : TeamsActivity +{ + /// + /// Base bot application. + /// + public TeamsBotApplication TeamsBotApplication { get; } = botApplication; + + /// + /// Current activity. + /// + public TActivity Activity { get; } = activity; + + /// + /// Gets the application (client) ID configured for this bot. + /// + public string AppId => TeamsBotApplication.AppId; + + private ContextLogger? _log; + + /// + /// Gets the logger for this context, providing .Info(), .Error(), .Debug(), + /// and .Warn() convenience methods that delegate to the underlying . + /// + public ContextLogger Log => _log ??= new ContextLogger(TeamsBotApplication.Logger); + + private ApiClient? _api; + + /// + /// Gets the scoped to the current activity's service URL. + /// + public ApiClient Api => _api ??= TeamsBotApplication.Api.ForServiceUrl( + Activity.ServiceUrl ?? throw new InvalidOperationException("Activity.ServiceUrl is required to use the Api client.")); + + // ==================== Convenience Send/Reply/Typing ==================== + + /// + /// Sends a text message to the conversation. + /// + /// The text to send. + /// A cancellation token. + /// The response from the send operation. + public Task Send(string text, CancellationToken cancellationToken = default) + => SendActivityAsync(text, cancellationToken); + + /// + /// Sends an activity to the conversation. + /// + /// The activity to send. + /// A cancellation token. + /// The response from the send operation. + public Task Send(TeamsActivity activity, CancellationToken cancellationToken = default) + => SendActivityAsync(activity, cancellationToken); + + /// + /// Sends a text message as a threaded reply to the current activity. When the inbound activity + /// has an id, the response auto-quotes it (rendered as a quote bubble above the response in Teams); + /// otherwise sends without quoting. + /// + /// The text to send. + /// A cancellation token. + /// The response from the send operation. + public Task Reply(string text, CancellationToken cancellationToken = default) + => Reply(new MessageActivity(text), cancellationToken); + + /// + /// Sends an activity to the conversation. When the inbound activity has an id, the response + /// auto-quotes it (rendered as a quote bubble above the response in Teams). Otherwise sends + /// without quoting. To send without quoting unconditionally, use . + /// + /// The activity to send. + /// A cancellation token. + /// The response from the send operation. + public Task Reply(TeamsActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); +#pragma warning disable ExperimentalTeamsQuotedReplies + if (!string.IsNullOrWhiteSpace(Activity.Id)) + { + return Quote(Activity.Id, activity, cancellationToken); + } +#pragma warning restore ExperimentalTeamsQuotedReplies + return SendActivityAsync(activity, cancellationToken); + } + + /// + /// Sends a typing indicator to the conversation. + /// + /// Reserved for future use; currently ignored. + /// A cancellation token. + /// The response from the send operation. + public Task Typing(string? text = null, CancellationToken cancellationToken = default) + => SendTypingActivityAsync(cancellationToken); + + /// + /// Send a message to the conversation with a quoted message reference prepended to the text. + /// Teams renders the quoted message as a preview bubble above the response text. + /// + /// The ID of the message to quote. + /// The response text, appended to the quoted message placeholder. + /// Optional cancellation token. + /// The response from sending the activity. + [Experimental("ExperimentalTeamsQuotedReplies")] + public Task Quote(string messageId, string text, CancellationToken cancellationToken = default) + => Quote(messageId, new MessageActivity(text), cancellationToken); + + /// + /// Send a message to the conversation with a quoted message reference prepended to the text. + /// Teams renders the quoted message as a preview bubble above the response text. + /// + /// The ID of the message to quote. + /// The activity to send. For , a quote placeholder for messageId is prepended to its text. Other activity types are sent as-is without quoting. + /// Optional cancellation token. + /// The response from sending the activity. + [Experimental("ExperimentalTeamsQuotedReplies")] + public Task Quote(string messageId, TeamsActivity activity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(messageId); + if (activity is MessageActivity message) + { + message.PrependQuote(messageId); + } + return SendActivityAsync(activity, cancellationToken); + } + + // ==================== Core Send Methods ==================== + + /// + /// Sends a message activity as a reply. + /// + /// The text to send. + /// A cancellation token. + /// The response from the send operation. + public Task SendActivityAsync(string text, CancellationToken cancellationToken = default) + { + TeamsActivity reply = new TeamsActivityBuilder() + .WithConversationReference(Activity) + .WithText(text) + .Build(); + return TeamsBotApplication.SendActivityAsync(reply, cancellationToken: cancellationToken); + } + + /// + /// Sends an activity to the conversation. + /// + /// The activity to send. + /// A cancellation token. + /// The response from the send operation. + public Task SendActivityAsync(TeamsActivity activity, CancellationToken cancellationToken = default) + { + TeamsActivity reply = new TeamsActivityBuilder(activity) + .WithConversationReference(Activity) + .Build(); + return TeamsBotApplication.SendActivityAsync(reply, cancellationToken: cancellationToken); + } + + /// + /// Sends a typing activity to the conversation asynchronously. + /// + /// A cancellation token. + /// The response from the send operation. + public Task SendTypingActivityAsync(CancellationToken cancellationToken = default) + { + TeamsActivity reply = new TeamsActivityBuilder() + .WithType(TeamsActivityType.Typing) + .WithConversationReference(Activity) + .Build(); + return TeamsBotApplication.SendActivityAsync(reply, cancellationToken: cancellationToken); + } + + // ==================== OAuth Sign-In ==================== + + /// + /// Trigger user OAuth sign-in flow for the activity sender. + /// Attempts silent token acquisition first; if no token is cached, sends an OAuthCard. + /// + /// OAuth options including connection name and card text. + /// A cancellation token. + /// The existing user token if found, or null if the sign-in flow was initiated. + public Task SignIn(OAuthOptions? options = null, CancellationToken cancellationToken = default) + { + OAuthFlow flow = ResolveOAuthFlow(options?.ConnectionName); + return flow.SignInAsync(this, options, cancellationToken); + } + + /// + /// Sign the user out, revoking their token from the Bot Framework Token Store. + /// + /// The connection name to sign out from. If null, uses the default registered connection. + /// A cancellation token. + public Task SignOut(string? connectionName = null, CancellationToken cancellationToken = default) + { + OAuthFlow flow = ResolveOAuthFlow(connectionName); + return flow.SignOutAsync(this, cancellationToken); + } + + /// + /// Whether the activity sender has a valid cached token. + /// When a single OAuthFlow is registered, checks that connection. + /// When multiple are registered, checks the first one and logs a warning; + /// prefer with an explicit connection name instead. + /// Returns false if no OAuthFlow is registered. + /// + /// + /// This property blocks the calling thread (sync-over-async) while querying + /// the Bot Framework Token Service. Under high concurrency this can cause + /// thread-pool starvation. Prefer in new code. + /// + [Obsolete("Use IsSignedInAsync() instead. This property blocks the calling thread and can cause thread-pool starvation under load.")] + public bool IsSignedIn + { + get + { + OAuthFlowRegistry? registry = TeamsBotApplication.OAuthRegistry; + if (registry is null) return false; + + OAuthFlow? flow = registry.ResolveSingleWithWarning(); + if (flow is null) return false; + + return flow.GetTokenAsync(this).GetAwaiter().GetResult() is not null; + } + } + + /// + /// Check whether the user has a valid cached token for a given OAuth connection. + /// + /// The connection name to check. If null, uses the single registered connection. + /// A cancellation token. + /// True if the user has a valid token; false otherwise. + public Task IsSignedInAsync(string? connectionName = null, CancellationToken cancellationToken = default) + { + OAuthFlow flow = ResolveOAuthFlow(connectionName); + return flow.IsSignedInAsync(this, cancellationToken); + } + + /// + /// Get the token status for all configured OAuth connections. + /// Returns every connection registered on the bot, so the developer + /// never needs to enumerate connection names manually. + /// + /// A cancellation token. + /// A list of token status results for all configured connections. + public Task> GetConnectionStatusAsync(CancellationToken cancellationToken = default) + { + OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry + ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow(connectionName) on the TeamsBotApplication first."); + + // Use any flow -- GetConnectionStatusAsync returns all connections regardless + OAuthFlow flow = registry.ResolveSingle() + ?? registry.GetAllFlows().First(); + + return flow.GetConnectionStatusAsync(this, cancellationToken); + } + + private OAuthFlow ResolveOAuthFlow(string? connectionName) + { + OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry + ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow(connectionName) on the TeamsBotApplication first."); + + if (connectionName is not null) + { + OAuthFlow? flow = registry.Resolve(connectionName); + if (flow is not null) return flow; + + string registered = string.Join(", ", registry.GetRegisteredConnectionNames().Select(n => $"'{n}'")); + throw new InvalidOperationException( + $"No OAuthFlow registered for connection '{connectionName}'. " + + $"Registered connections: {(registered.Length > 0 ? registered : "(none)")}."); + } + + return registry.ResolveSingle() + ?? throw new InvalidOperationException( + "Multiple OAuthFlow instances registered. Specify a connection name in OAuthOptions or SignOut(connectionName)."); + } +} diff --git a/core/src/Microsoft.Teams.Apps/ContextLogger.cs b/core/src/Microsoft.Teams.Apps/ContextLogger.cs new file mode 100644 index 000000000..70e41668c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/ContextLogger.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Apps; + +/// +/// Provides backward-compatible logging methods (.Info(), .Error(), .Debug(), .Warn()) +/// that delegate to an underlying instance. +/// +/// The underlying logger to delegate to. +public class ContextLogger(ILogger logger) +{ + /// + /// Gets the underlying instance. + /// + public ILogger Logger { get; } = logger; + + /// + /// Logs a message at the level. + /// + /// The message arguments. The first string argument is used as the message template. + public void Info(params object?[] args) + { + ArgumentNullException.ThrowIfNull(args); + if (!Logger.IsEnabled(LogLevel.Information)) return; + Logger.LogInformation("{Message}", FormatArgs(args)); + } + + /// + /// Logs a message at the level. + /// + /// The message arguments. The first string argument is used as the message template. + public void Error(params object?[] args) + { + ArgumentNullException.ThrowIfNull(args); + if (!Logger.IsEnabled(LogLevel.Error)) return; + Logger.LogError("{Message}", FormatArgs(args)); + } + + /// + /// Logs a message at the level. + /// + /// The message arguments. The first string argument is used as the message template. + public void Debug(params object?[] args) + { + ArgumentNullException.ThrowIfNull(args); + if (!Logger.IsEnabled(LogLevel.Debug)) return; + Logger.LogDebug("{Message}", FormatArgs(args)); + } + + /// + /// Logs a message at the level. + /// + /// The message arguments. The first string argument is used as the message template. + public void Warn(params object?[] args) + { + ArgumentNullException.ThrowIfNull(args); + if (!Logger.IsEnabled(LogLevel.Warning)) return; + Logger.LogWarning("{Message}", FormatArgs(args)); + } + + private static string FormatArgs(object?[] args) + { + return args.Length switch + { + 0 => string.Empty, + 1 => args[0]?.ToString() ?? string.Empty, + _ => string.Join(" ", args.Select(a => a?.ToString() ?? "null")) + }; + } +} diff --git a/core/src/Microsoft.Teams.Apps/GlobalSuppressions.cs b/core/src/Microsoft.Teams.Apps/GlobalSuppressions.cs new file mode 100644 index 000000000..215073623 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/GlobalSuppressions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Performance", + "CA1873:Avoid potentially expensive logging", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Apps")] + +[assembly: SuppressMessage("Performance", + "CA1848:Use the LoggerMessage delegates", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Apps")] + +[assembly: SuppressMessage("Usage", + "CA2227:Collection properties should be read only", + Justification = "", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Apps")] diff --git a/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.ActionValue.cs b/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.ActionValue.cs new file mode 100644 index 000000000..66b38f3be --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.ActionValue.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Defines the structure that arrives in the Activity.Value for Invoke activity with +/// Name of 'adaptiveCard/action'. +/// +public class AdaptiveCardActionValue +{ + /// + /// The action of this adaptive card invoke action value. + /// + [JsonPropertyName("action")] + public AdaptiveCardAction? Action { get; set; } + + /// + /// The state for this adaptive card invoke action value. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// What triggered the action. + /// + [JsonPropertyName("trigger")] + public string? Trigger { get; set; } +} + +/// +/// Defines the structure that arrives in the Activity.Value.Action for Invoke +/// activity with Name of 'adaptiveCard/action'. +/// +public class AdaptiveCardAction +{ + /// + /// The Type of this Adaptive Card Invoke Action. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The id of this Adaptive Card Invoke Action. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The title of this Adaptive Card Invoke Action. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The Verb of this adaptive card action invoke. + /// + [JsonPropertyName("verb")] + public string? Verb { get; set; } + + /// + /// The Data of this adaptive card action invoke. + /// + [JsonPropertyName("data")] + public Dictionary? Data { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.Response.cs b/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.Response.cs new file mode 100644 index 000000000..4ae19e98a --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.Response.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Adaptive card response types. +/// +public static class AdaptiveCardResponseType +{ + /// + /// Message type - displays a message to the user. + /// + public const string Message = "application/vnd.microsoft.activity.message"; + + /// + /// Card type - updates the card with new content. + /// + public const string Card = "application/vnd.microsoft.card.adaptive"; +} + +/// +/// Response for adaptive card action activities. +/// +public class AdaptiveCardResponse +{ + /// + /// HTTP status code for the response. + /// + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } = 200; + + /// + /// Type of response. See for common values. + /// + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; set; } + + /// + /// Value for the response. Can be a string message or card content. + /// + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Value { get; set; } + + /// + /// Creates a new builder for AdaptiveCardResponse. + /// + public static AdaptiveCardResponseBuilder CreateBuilder() + { + return new AdaptiveCardResponseBuilder(); + } + + /// + /// Creates an with a message response. + /// + /// The message to display to the user. + /// The HTTP status code (default: 200). + public static InvokeResponse CreateMessageResponse(string message, int statusCode = 200) + { + return new InvokeResponse(statusCode, new AdaptiveCardResponse + { + StatusCode = statusCode, + Type = AdaptiveCardResponseType.Message, + Value = message + }); + } + + /// + /// Creates an with a card response. + /// + /// The card content to display. + /// The HTTP status code (default: 200). + public static InvokeResponse CreateCardResponse(object card, int statusCode = 200) + { + return new InvokeResponse(statusCode, new AdaptiveCardResponse + { + StatusCode = statusCode, + Type = AdaptiveCardResponseType.Card, + Value = card + }); + } +} + +/// +/// Builder for AdaptiveCardResponse. +/// +public class AdaptiveCardResponseBuilder +{ + private int _statusCode = 200; + private string? _type; + private object? _value; + + /// + /// Sets the HTTP status code for the response. + /// + public AdaptiveCardResponseBuilder WithStatusCode(int statusCode) + { + _statusCode = statusCode; + return this; + } + + /// + /// Sets the type of the response. See for common values. + /// + public AdaptiveCardResponseBuilder WithType(string type) + { + _type = type; + return this; + } + + /// + /// Sets the value for the response. + /// + public AdaptiveCardResponseBuilder WithValue(object value) + { + _value = value; + return this; + } + + /// + /// Builds the and wraps it in an . + /// + public InvokeResponse Build() + { + return new InvokeResponse(_statusCode, new AdaptiveCardResponse + { + StatusCode = _statusCode, + Type = _type, + Value = _value + }); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.cs new file mode 100644 index 000000000..e4ccc2740 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/AdaptiveCardHandler.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling adaptive card action invoke activities. +/// +/// The context for the invoke activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task that represents the asynchronous operation. The task result contains the invoke response. +public delegate Task AdaptiveCardActionHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering adaptive card action invoke handlers. +/// +public static class AdaptiveCardExtensions +{ + /// + /// Registers a handler for adaptive card action invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnAdaptiveCardAction(this TeamsBotApplication app, AdaptiveCardActionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.AdaptiveCardAction), + Selector = activity => activity.Name == InvokeNames.AdaptiveCardAction, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/ConversationUpdateHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/ConversationUpdateHandler.Activity.cs new file mode 100644 index 000000000..56dded88e --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/ConversationUpdateHandler.Activity.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Represents a conversation update activity. +/// +public class ConversationUpdateActivity : TeamsActivity +{ + /// + /// Convenience method to create a ConversationUpdateActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A ConversationUpdateActivity instance. + public static new ConversationUpdateActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new ConversationUpdateActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public ConversationUpdateActivity() : base(TeamsActivityType.ConversationUpdate) + { + } + + /// + /// Internal constructor to create ConversationUpdateActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected ConversationUpdateActivity(CoreActivity activity) : base(activity) + { + /* + if (activity.Properties.TryGetValue("topicName", out var topicName)) + { + TopicName = topicName?.ToString(); + activity.Properties.Remove("topicName"); + } + */ + + MembersAdded = activity.Properties.Extract>("membersAdded"); + MembersRemoved = activity.Properties.Extract>("membersRemoved"); + } + + //TODO : review properties + /* + /// + /// Gets or sets the updated topic name of the conversation. + /// + [JsonPropertyName("topicName")] + public string? TopicName { get; set; } + */ + + /// + /// Gets or sets the collection of members added to the conversation. + /// + [JsonPropertyName("membersAdded")] + public IList? MembersAdded { get; set; } + + /// + /// Gets or sets the collection of members removed from the conversation. + /// + [JsonPropertyName("membersRemoved")] + public IList? MembersRemoved { get; set; } +} + +/// +/// String constants for conversation event types. +/// +public static class ConversationEventTypes +{ + /// + /// Channel created event. + /// + public const string ChannelCreated = "channelCreated"; + + /// + /// Channel deleted event. + /// + public const string ChannelDeleted = "channelDeleted"; + + /// + /// Channel renamed event. + /// + public const string ChannelRenamed = "channelRenamed"; + + + /// + /// Channel shared event. + /// + public const string ChannelShared = "channelShared"; + + /// + /// Channel unshared event. + /// + public const string ChannelUnShared = "channelUnshared"; + + /// + /// Channel member added event. + /// + public const string ChannelMemberAdded = "channelMemberAdded"; + + /// + /// Channel member removed event. + /// + public const string ChannelMemberRemoved = "channelMemberRemoved"; + + //TODO : review these events + /* + /// + /// Channel restored event. + /// + public const string ChannelRestored = "channelRestored"; + */ + + /// + /// Team member added event. + /// + public const string TeamMemberAdded = "teamMemberAdded"; + + /// + /// Team member removed event. + /// + public const string TeamMemberRemoved = "teamMemberRemoved"; + + /// + /// Team archived event. + /// + public const string TeamArchived = "teamArchived"; + + /// + /// Team deleted event. + /// + public const string TeamDeleted = "teamDeleted"; + + /// + /// Team renamed event. + /// + public const string TeamRenamed = "teamRenamed"; + + /// + /// Team unarchived event. + /// + public const string TeamUnarchived = "teamUnarchived"; + + /*TODO : review these events + /// + /// Team hard deleted event. + /// + public const string TeamHardDeleted = "teamHardDeleted"; + + /// + /// Team restored event. + /// + public const string TeamRestored = "teamRestored"; + */ +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/ConversationUpdateHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/ConversationUpdateHandler.cs new file mode 100644 index 000000000..9c2fb4f26 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/ConversationUpdateHandler.cs @@ -0,0 +1,483 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling conversation update activities. +/// +/// The context for the conversation update activity. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task ConversationUpdateHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering conversation update activity handlers. +/// +public static class ConversationUpdateExtensions +{ + /// + /// Registers a handler for conversation update activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnConversationUpdate(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.ConversationUpdate, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for conversation update activities where members were added. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMembersAdded(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, "membersAdded"]), + Selector = activity => activity.MembersAdded?.Count > 0, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for conversation update activities where members were removed. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMembersRemoved(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, "membersRemoved"]), + Selector = activity => activity.MembersRemoved?.Count > 0, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + // Channel Event Handlers + + /// + /// Registers a handler for channel created events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnChannelCreated(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelCreated]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelCreated, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel deleted events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnChannelDeleted(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelDeleted]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelDeleted, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel renamed events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnChannelRenamed(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelRenamed]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelRenamed, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel shared events. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnChannelShared(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelShared]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelShared, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel unshared events. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnChannelUnshared(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelUnShared]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelUnShared, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + + /// + /// Registers a handler for channel member added events. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnChannelMemberAdded(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelMemberAdded]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelMemberAdded, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for channel member removed events. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnChannelMemberRemoved(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelMemberRemoved]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelMemberRemoved, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /* + /// + /// Registers a handler for channel restored events. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnChannelRestored(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.ChannelRestored]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.ChannelRestored, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + */ + + // Team Event Handlers + + /// + /// Registers a handler for team member added events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTeamMemberAdded(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamMemberAdded]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamMemberAdded, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team member removed events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTeamMemberRemoved(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamMemberRemoved]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamMemberRemoved, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team archived events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTeamArchived(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamArchived]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamArchived, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team deleted events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTeamDeleted(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamDeleted]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamDeleted, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team renamed events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTeamRenamed(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamRenamed]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamRenamed, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team unarchived events. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTeamUnarchived(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamUnarchived]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamUnarchived, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /* + /// Registers a handler for team restored events. + ///
+ /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTeamRestored(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamRestored]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamRestored, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for team hard deleted events. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTeamHardDeleted(this TeamsBotApplication app, ConversationUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.ConversationUpdate, ConversationEventTypes.TeamHardDeleted]), + Selector = activity => activity.ChannelData?.EventType == ConversationEventTypes.TeamHardDeleted, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + */ +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/EventHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/EventHandler.Activity.cs new file mode 100644 index 000000000..6851f93dd --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/EventHandler.Activity.cs @@ -0,0 +1,126 @@ +// 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.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Represents an event activity. +/// +public class EventActivity : TeamsActivity +{ + /// + /// Creates an EventActivity from a CoreActivity. + /// + public static new EventActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new EventActivity(activity); + } + + /// + /// Gets or sets the name of the event. See for common values. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the value payload of the event activity. + /// + [JsonPropertyName("value")] + public JsonNode? Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public EventActivity() : base(TeamsActivityType.Event) + { + } + + /// + /// Initializes a new instance of the class with the specified name. + /// + public EventActivity(string name) : base(TeamsActivityType.Event) + { + Name = name; + } + + /// + /// Initializes a new instance of the class from a CoreActivity. + /// + protected EventActivity(CoreActivity activity) : base(activity) + { + Name = activity.Properties.Extract("name"); + Value = activity is EventActivity evt + ? evt.Value + : activity.Properties.Extract("value"); + } +} + +/// +/// Represents an event activity with a strongly-typed value. +/// +/// The type of the value payload. +public class EventActivity : EventActivity +{ + /// + /// Gets or sets the strongly-typed value associated with the event activity. + /// Shadows the base class Value property, deserializing from the underlying JsonNode on access. + /// + public new TValue? Value + { + get => base.Value != null ? JsonSerializer.Deserialize(base.Value.ToJsonString()) : default; + set => base.Value = value != null ? JsonSerializer.SerializeToNode(value) : null; + } + + /// + /// Initializes a new instance of the class. + /// + public EventActivity() : base() + { + } + + /// + /// Initializes a new instance of the class with the specified name. + /// + public EventActivity(string name) : base(name) + { + } + + /// + /// Initializes a new instance of the class from an EventActivity. + /// + public EventActivity(EventActivity activity) : base(activity) + { + } +} + +/// +/// String constants for event activity names. +/// +public static class EventNames +{ + /// Meeting start event name. + public const string MeetingStart = "application/vnd.microsoft.meetingStart"; + + /// Meeting end event name. + public const string MeetingEnd = "application/vnd.microsoft.meetingEnd"; + + /// Meeting participant join event name. + public const string MeetingParticipantJoin = "application/vnd.microsoft.meetingParticipantJoin"; + + /// Meeting participant leave event name. + public const string MeetingParticipantLeave = "application/vnd.microsoft.meetingParticipantLeave"; + + //TODO : review read receipts + /* + /// Read receipt event name. Fired when a user reads a message in a 1:1 chat with the bot. + public const string ReadReceipt = "application/vnd.microsoft.readReceipt"; + */ +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/EventHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/EventHandler.cs new file mode 100644 index 000000000..acab52c34 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/EventHandler.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling any event activity. +/// +/// The context for the event activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task EventActivityHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering generic event activity handlers. +/// +public static class EventExtensions +{ + /// + /// Registers a handler for all event activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnEvent(this TeamsBotApplication app, EventActivityHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.Event, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /* + /// + /// Registers a handler for read receipt event activities. + /// Fired by Teams when a user reads a message sent by the bot in a 1:1 chat. + /// No value payload — the event itself is the notification. + /// + public static TeamsBotApplication OnReadReceipt(this TeamsBotApplication app, EventActivityHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.ReadReceipt), + Selector = activity => activity.Name == EventNames.ReadReceipt, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + */ +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/FileConsentHandler.Value.cs b/core/src/Microsoft.Teams.Apps/Handlers/FileConsentHandler.Value.cs new file mode 100644 index 000000000..a40bd98b3 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/FileConsentHandler.Value.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Represents the value of the invoke activity sent when the user acts on a +/// file consent card. +/// +public class FileConsentValue +{ + /// + /// The type of file consent activity. Typically "fileUpload". + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The action the user took. Possible values: 'accept', 'decline'. + /// + [JsonPropertyName("action")] + public string? Action { get; set; } + + /// + /// The context associated with the action. + /// + [JsonPropertyName("context")] + public object? Context { get; set; } + + /// + /// If the user accepted the file, + /// contains information about the file to be uploaded. + /// + [JsonPropertyName("uploadInfo")] + public FileUploadInfo? UploadInfo { get; set; } +} + +/// +/// File upload info for accepted file consent. +/// +public class FileUploadInfo +{ + /// + /// Name of the file. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// URL to upload file content. + /// + [JsonPropertyName("uploadUrl")] + public Uri? UploadUrl { get; set; } + + /// + /// URL to file content after upload. + /// + [JsonPropertyName("contentUrl")] + public Uri? ContentUrl { get; set; } + + /// + /// Unique ID for the file. + /// + [JsonPropertyName("uniqueId")] + public string? UniqueId { get; set; } + + /// + /// Type of the file. + /// + [JsonPropertyName("fileType")] + public string? FileType { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/FileConsentHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/FileConsentHandler.cs new file mode 100644 index 000000000..588c4a193 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/FileConsentHandler.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling file consent invoke activities. +/// +/// The context for the invoke activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task that represents the asynchronous operation. The task result contains the invoke response. +public delegate Task> FileConsentValueHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering file consent invoke handlers. +/// +public static class FileConsentExtensions +{ + + /// + /// Registers a handler for file consent invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnFileConsent(this TeamsBotApplication app, FileConsentValueHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.FileConsent), + Selector = activity => activity.Name == InvokeNames.FileConsent, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/InstallUpdateHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/InstallUpdateHandler.Activity.cs new file mode 100644 index 000000000..2752e4979 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/InstallUpdateHandler.Activity.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Represents an installation update activity. +/// +public class InstallUpdateActivity : TeamsActivity +{ + /// + /// Convenience method to create an InstallUpdateActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// An InstallUpdateActivity instance. + public static new InstallUpdateActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new InstallUpdateActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public InstallUpdateActivity() : base(TeamsActivityType.InstallationUpdate) + { + } + + /// + /// Internal constructor to create InstallUpdateActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected InstallUpdateActivity(CoreActivity activity) : base(activity) + { + ArgumentNullException.ThrowIfNull(activity); + Action = activity.Properties.Extract("action"); + } + + /// + /// Gets or sets the action for the installation update. See for known values. + /// + [JsonPropertyName("action")] + public string? Action { get; set; } +} + +/// +/// String constants for installation update actions. +/// +public static class InstallUpdateActions +{ + /// + /// Add action constant. + /// + public const string Add = "add"; + + /// + /// Remove action constant. + /// + public const string Remove = "remove"; +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/InstallUpdateHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/InstallUpdateHandler.cs new file mode 100644 index 000000000..fab895ba9 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/InstallUpdateHandler.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling installation update activities. +/// +/// The context for the installation update activity. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task InstallUpdateHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering installation update activity handlers. +/// +public static class InstallUpdateExtensions +{ + /// + /// Registers a handler for installation update activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnInstallUpdate(this TeamsBotApplication app, InstallUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.InstallationUpdate, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for installation add activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnInstall(this TeamsBotApplication app, InstallUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.InstallationUpdate, InstallUpdateActions.Add]), + Selector = activity => activity.Action == InstallUpdateActions.Add, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for installation remove activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnUnInstall(this TeamsBotApplication app, InstallUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.InstallationUpdate, InstallUpdateActions.Remove]), + Selector = activity => activity.Action == InstallUpdateActions.Remove, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs new file mode 100644 index 000000000..ff0cfb510 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs @@ -0,0 +1,244 @@ +// 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.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Represents an invoke activity. +/// +public class InvokeActivity : TeamsActivity +{ + /// + /// Creates an InvokeActivity from a CoreActivity. + /// + /// The core activity to convert. + /// An instance. + public static new InvokeActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new InvokeActivity(activity); + } + + /// + /// Gets or sets the name of the operation. See for common values. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the value payload of the invoke activity. + /// + [JsonPropertyName("value")] + public JsonNode? Value { get; set; } + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public InvokeActivity() : base(TeamsActivityType.Invoke) + { + } + + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The invoke operation name. + + public InvokeActivity(string name) : base(TeamsActivityType.Invoke) + { + Name = name; + } + + /// + /// Initializes a new instance of the InvokeActivity class with the specified core activity. + /// + /// The core activity to be invoked. Cannot be null. + protected InvokeActivity(CoreActivity activity) : base(activity) + { + ArgumentNullException.ThrowIfNull(activity); + Name = activity.Properties.Extract("name"); + Value = activity is InvokeActivity invoke + ? invoke.Value + : activity.Properties.Extract("value"); + } +} + +/// +/// Represents an invoke activity with a strongly-typed value. +/// +/// +/// The strongly-typed Value property provides compile-time type safety while maintaining a single storage location +/// through the base class. Both the typed and untyped Value properties access the same underlying JsonNode value. +/// +/// The type of the value payload. +public class InvokeActivity : InvokeActivity +{ + /// + /// Gets or sets the strongly-typed value associated with the invoke activity. + /// This property shadows the base class Value property but uses the same underlying storage, + /// ensuring no synchronization issues between typed and untyped access. + /// + public new TValue? Value + { + get => base.Value != null ? JsonSerializer.Deserialize(base.Value.ToJsonString()) : default; + set => base.Value = value != null ? JsonSerializer.SerializeToNode(value) : null; + } + + /// + /// Initializes a new instance of the class. + /// + public InvokeActivity() : base() + { + } + + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The invoke operation name. + public InvokeActivity(string name) : base(name) + { + } + + /// + /// Initializes a new instance of the class from an InvokeActivity. + /// + /// The invoke activity. + public InvokeActivity(InvokeActivity activity) : base(activity) + { + } +} + +/// +/// String constants for invoke activity names. +/// +public static class InvokeNames +{ + /// + /// File consent invoke name. + /// + public const string FileConsent = "fileConsent/invoke"; + + /// + /// Adaptive card action invoke name. + /// + public const string AdaptiveCardAction = "adaptiveCard/action"; + + /// + /// Tab fetch invoke name. + /// + public const string TabFetch = "tab/fetch"; + + /// + /// Tab submit invoke name. + /// + public const string TabSubmit = "tab/submit"; + + /// + /// Task fetch invoke name. + /// + public const string TaskFetch = "task/fetch"; + + /// + /// Task submit invoke name. + /// + public const string TaskSubmit = "task/submit"; + + /// + /// Sign-in token exchange invoke name. + /// + public const string SignInTokenExchange = "signin/tokenExchange"; + + /// + /// Sign-in verify state invoke name. + /// + public const string SignInVerifyState = "signin/verifyState"; + + /// + /// Sign-in failure invoke name. Sent by the Teams client when SSO token exchange + /// fails client-side (e.g., misconfigured Entra app registration). + /// + public const string SignInFailure = "signin/failure"; + + /// + /// Message extension anonymous query link invoke name. + /// + public const string MessageExtensionAnonQueryLink = "composeExtension/anonymousQueryLink"; + + /// + /// Message extension fetch task invoke name. + /// + public const string MessageExtensionFetchTask = "composeExtension/fetchTask"; + + /// + /// Message extension query invoke name. + /// + public const string MessageExtensionQuery = "composeExtension/query"; + + /// + /// Message extension query link invoke name. + /// + public const string MessageExtensionQueryLink = "composeExtension/queryLink"; + + /// + /// Message extension query setting URL invoke name. + /// + public const string MessageExtensionQuerySettingUrl = "composeExtension/querySettingUrl"; + + /// + /// Message extension select item invoke name. + /// + public const string MessageExtensionSelectItem = "composeExtension/selectItem"; + + /// + /// Message extension submit action invoke name. + /// + public const string MessageExtensionSubmitAction = "composeExtension/submitAction"; + + /// + /// Message submit action invoke name. + /// + public const string MessageSubmitAction = "message/submitAction"; + + //TODO : review + /* + /// + /// Execute action invoke name. + /// + public const string ExecuteAction = "actionableMessage/executeAction"; + + /// + /// Handoff invoke name. + /// + public const string Handoff = "handoff/action"; + + /// + /// Search invoke name. + /// + public const string Search = "search"; + /// + /// Config fetch invoke name. + /// + public const string ConfigFetch = "config/fetch"; + + /// + /// Config submit invoke name. + /// + public const string ConfigSubmit = "config/submit"; + + /// + /// Message extension card button clicked invoke name. + /// + public const string MessageExtensionCardButtonClicked = "composeExtension/onCardButtonClicked"; + + /// + /// Message extension setting invoke name. + /// + public const string MessageExtensionSetting = "composeExtension/setting"; + */ +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Response.cs b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Response.cs new file mode 100644 index 000000000..616a1d39a --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Response.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers; + + +/// +/// Represents the response returned from an invocation handler, typically used for Adaptive Card actions and task module operations. +/// +/// +/// This class encapsulates the HTTP-style response sent back to Teams when handling invoke activities. +/// Common status codes include 200 for success, 400 for bad request, and 500 for errors. +/// The Body property contains the response payload, which is serialized to JSON and returned to the client. +/// +/// The HTTP status code indicating the result of the invoke operation (e.g., 200 for success). +/// Optional response payload that will be serialized and sent to the client. +public class InvokeResponse(int status, object? body = null) +{ + /// + /// Status code of the response. + /// + [JsonPropertyName("status")] + public int Status { get; set; } = status; + + /// + /// Gets or sets the response body. + /// + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Body { get; set; } = body; + + /// + /// Creates a successful (200) invoke response with the specified body. + /// + /// The response payload. + /// An with status 200. + public static InvokeResponse Ok(object? body = null) => new(200, body); + + /// + /// Creates a successful (200) strongly-typed invoke response with the specified body. + /// + /// The type of the response body. + /// The response payload. + /// An with status 200. + public static InvokeResponse
Ok(TBody body) where TBody : notnull => new(200, body); + + /// + /// Creates an error invoke response with the specified status code and optional body. + /// + /// The HTTP error status code. + /// Optional error details. + /// An with the specified error status. + public static InvokeResponse Error(int status, object? body = null) => new(status, body); +} + +/// +/// Represents a strongly-typed response returned from an invocation handler. +/// +/// +/// The strongly-typed Body property provides compile-time type safety while maintaining a single storage location +/// through the base class. Both the typed and untyped Body properties access the same underlying body. +/// +/// The type of the response body. +/// The HTTP status code indicating the result of the invoke operation (e.g., 200 for success). +/// Optional strongly-typed response payload that will be serialized and sent to the client. +public class InvokeResponse(int status, TBody? body = default) : InvokeResponse(status, body) where TBody : notnull +{ + /// + /// Gets or sets the strongly-typed response body. + /// This property shadows the base class Body property but uses the same underlying storage, + /// ensuring no synchronization issues between typed and untyped access. + /// + public new TBody? Body + { + get => (TBody?)base.Body; + set => base.Body = value; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.cs new file mode 100644 index 000000000..b4a26014e --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.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); + +/// +/// Provides extension methods for registering handlers for invoke activities in a Teams bot application. +/// +public static class InvokeExtensions +{ + /// + /// Registers a catch-all handler for all invoke activities. + /// Cannot be combined with specific invoke handlers such as , + /// , etc. + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The invoke handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnInvoke(this TeamsBotApplication app, InvokeHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.Invoke, + Selector = _ => true, + HandlerWithReturn = async (ctx, cancellationToken) => + { + return await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MeetingHandler.Values.cs b/core/src/Microsoft.Teams.Apps/Handlers/MeetingHandler.Values.cs new file mode 100644 index 000000000..dccda2e6b --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MeetingHandler.Values.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Value payload for a meeting start event. +/// +public class MeetingStartValue +{ + /// The meeting's Id, encoded as a BASE64 string. + [JsonPropertyName("Id")] + public required string Id { get; set; } + + /// The meeting's type. + [JsonPropertyName("MeetingType")] + public string? MeetingType { get; set; } = string.Empty; + + /// The URL used to join the meeting. + [JsonPropertyName("JoinUrl")] + public Uri? JoinUrl { get; set; } + + /// The title of the meeting. + [JsonPropertyName("Title")] + public string? Title { get; set; } = string.Empty; + + /// Timestamp for meeting start, in UTC. + [JsonPropertyName("StartTime")] + public string? StartTime { get; set; } +} + +/// +/// Value payload for a meeting end event. +/// +public class MeetingEndValue +{ + /// The meeting's Id, encoded as a BASE64 string. + [JsonPropertyName("Id")] + public required string Id { get; set; } + + /// The meeting's type. + [JsonPropertyName("MeetingType")] + public string? MeetingType { get; set; } + + /// The URL used to join the meeting. + [JsonPropertyName("JoinUrl")] + public Uri? JoinUrl { get; set; } + + /// The title of the meeting. + [JsonPropertyName("Title")] + public string? Title { get; set; } + + /// Timestamp for meeting end, in UTC. + [JsonPropertyName("EndTime")] + public string? EndTime { get; set; } +} + +/// +/// Value payload for a meeting participant join event. +/// +public class MeetingParticipantJoinValue +{ + /// The list of participants who joined. + [JsonPropertyName("members")] + public IList Members { get; set; } = []; +} + +/// +/// Value payload for a meeting participant leave event. +/// +public class MeetingParticipantLeaveValue +{ + /// The list of participants who left. + [JsonPropertyName("members")] + public IList Members { get; set; } = []; +} + +/// +/// Represents a member in a meeting participant event. +/// +public class MeetingParticipantMember +{ + /// The participant's account. + [JsonPropertyName("user")] + public TeamsConversationAccount User { get; set; } = new(); + + /// The participant's meeting info. + [JsonPropertyName("meeting")] + public MeetingParticipantInfo Meeting { get; set; } = new(); +} + +/// +/// Represents a participant's meeting info. +/// +public class MeetingParticipantInfo +{ + /// Whether the user is currently in the meeting. + [JsonPropertyName("inMeeting")] + public bool InMeeting { get; set; } + + /// The participant's role in the meeting. + [JsonPropertyName("role")] + public string? Role { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MeetingHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MeetingHandler.cs new file mode 100644 index 000000000..f8859e9dd --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MeetingHandler.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling meeting start event activities. +/// +/// The context for the event activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task MeetingStartHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling meeting end event activities. +/// +/// The context for the event activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task MeetingEndHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling meeting participant join event activities. +/// +/// The context for the event activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task MeetingParticipantJoinHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling meeting participant leave event activities. +/// +/// The context for the event activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task MeetingParticipantLeaveHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering meeting event activity handlers. +/// +public static class MeetingExtensions +{ + /// + /// Registers a handler for meeting start event activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMeetingStart(this TeamsBotApplication app, MeetingStartHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.MeetingStart), + Selector = activity => activity.Name == EventNames.MeetingStart, + Handler = async (ctx, cancellationToken) => + { + EventActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for meeting end event activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMeetingEnd(this TeamsBotApplication app, MeetingEndHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.MeetingEnd), + Selector = activity => activity.Name == EventNames.MeetingEnd, + Handler = async (ctx, cancellationToken) => + { + EventActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for meeting participant join event activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMeetingParticipantJoin(this TeamsBotApplication app, MeetingParticipantJoinHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.MeetingParticipantJoin), + Selector = activity => activity.Name == EventNames.MeetingParticipantJoin, + Handler = async (ctx, cancellationToken) => + { + EventActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for meeting participant leave event activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMeetingParticipantLeave(this TeamsBotApplication app, MeetingParticipantLeaveHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Event, EventNames.MeetingParticipantLeave), + Selector = activity => activity.Name == EventNames.MeetingParticipantLeave, + Handler = async (ctx, cancellationToken) => + { + EventActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for meeting participant join event activities. + /// Alias for . + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMeetingJoin(this TeamsBotApplication app, MeetingParticipantJoinHandler handler) + => app.OnMeetingParticipantJoin(handler); + + /// + /// Registers a handler for meeting participant leave event activities. + /// Alias for . + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMeetingLeave(this TeamsBotApplication app, MeetingParticipantLeaveHandler handler) + => app.OnMeetingParticipantLeave(handler); +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageDeleteHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageDeleteHandler.Activity.cs new file mode 100644 index 000000000..052195aa4 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageDeleteHandler.Activity.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Represents a message delete activity. +/// +public class MessageDeleteActivity : TeamsActivity +{ + /// + /// Convenience method to create a MessageDeleteActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A MessageDeleteActivity instance. + public static new MessageDeleteActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new MessageDeleteActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public MessageDeleteActivity() : base(TeamsActivityType.MessageDelete) + { + } + + /// + /// Internal constructor to create MessageDeleteActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected MessageDeleteActivity(CoreActivity activity) : base(activity) + { + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageDeleteHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageDeleteHandler.cs new file mode 100644 index 000000000..a15e7e0c9 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageDeleteHandler.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling message delete activities. +/// +/// The context for the message delete activity. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task MessageDeleteHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message delete activity handlers. +/// +public static class MessageDeleteExtensions +{ + /// + /// Registers a handler for message delete activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessageDelete(this TeamsBotApplication app, MessageDeleteHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.MessageDelete, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionAction.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionAction.cs new file mode 100644 index 000000000..05fb848d4 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionAction.cs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; + +namespace Microsoft.Teams.Apps.Handlers.MessageExtension; + +/// +/// Message extension command context values. +/// +public static class MessageExtensionCommandContext +{ + /// + /// Command invoked from a message (message action). + /// + public const string Message = "message"; + + /// + /// Command invoked from the compose box. + /// + public const string Compose = "compose"; + + /// + /// Command invoked from the command box. + /// + public const string CommandBox = "commandbox"; +} + +/// +/// Bot message preview action values. +/// +public static class BotMessagePreviewAction +{ + /// + /// User clicked edit on the preview. + /// + public const string Edit = "edit"; + + /// + /// User clicked send on the preview. + /// + public const string Send = "send"; +} + +/// +/// Context information for message extension actions. +/// +public class MessageExtensionContext +{ + /// + /// The theme of the Teams client. Common values: "default", "dark", "contrast". + /// + [JsonPropertyName("theme")] + public string? Theme { get; set; } +} + +/// +/// Message extension action payload for submit action and fetch task activities. +/// +public class MessageExtensionAction +{ + /// + /// Id of the command assigned by the bot. + /// + [JsonPropertyName("commandId")] + public required string CommandId { get; set; } + + /// + /// The context from which the command originates. + /// See for common values. + /// + [JsonPropertyName("commandContext")] + public required string CommandContext { get; set; } + + /// + /// Bot message preview action taken by user. + /// See for common values. + /// + [JsonPropertyName("botMessagePreviewAction")] + public string? BotMessagePreviewAction { get; set; } + + /// + /// The activity preview that was originally sent to Teams when showing the bot message preview. + /// This is sent back by Teams when the user clicks 'edit' or 'send' on the preview. + /// + // TODO : this needs to be activity type or something else - format is type, attachments[] + [JsonPropertyName("botActivityPreview")] + public IList? BotActivityPreview { get; set; } + + /// + /// Data included with the submit action. + /// + [JsonPropertyName("data")] + public object? Data { get; set; } + + /// + /// Message content sent as part of the command request when the command is invoked from a message. + /// + [JsonPropertyName("messagePayload")] + public MessagePayload? MessagePayload { get; set; } + + /// + /// Context information for the action. + /// + [JsonPropertyName("context")] + public MessageExtensionContext? Context { get; set; } +} + +/// +/// Represents the individual message within a chat or channel where a message +/// action is taken. +/// +public class MessagePayload +{ + /// + /// Unique id of the message. + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + /// + /// Timestamp of when the message was created. + /// + [JsonPropertyName("createdDateTime")] + public string? CreatedDateTime { get; set; } + + /// + /// Indicates whether a message has been soft deleted. + /// + [JsonPropertyName("deleted")] + public bool? Deleted { get; set; } + + /// + /// Subject line of the message. + /// + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + /// + /// The importance of the message. + /// + /// + /// See for common values. + /// + [JsonPropertyName("importance")] + public string? Importance { get; set; } + + /// + /// Locale of the message set by the client. + /// + [JsonPropertyName("locale")] + public string? Locale { get; set; } + + /// + /// Link back to the message. + /// + [JsonPropertyName("linkToMessage")] + public string? LinkToMessage { get; set; } + + /// + /// Sender of the message. + /// + [JsonPropertyName("from")] + public MessageFrom? From { get; set; } + + /// + /// Plaintext/HTML representation of the content of the message. + /// + [JsonPropertyName("body")] + public MessagePayloadBody? Body { get; set; } + + /// + /// How the attachment(s) are displayed in the message. + /// + [JsonPropertyName("attachmentLayout")] + public string? AttachmentLayout { get; set; } + + /// + /// Attachments in the message - card, image, file, etc. + /// + [JsonPropertyName("attachments")] + public IList? Attachments { get; set; } + + /// + /// List of entities mentioned in the message. + /// + [JsonPropertyName("mentions")] + public IList? Mentions { get; set; } + + /// + /// Reactions for the message. + /// + [JsonPropertyName("reactions")] + public IList? Reactions { get; set; } +} + +/// +/// Sender of the message. +/// +public class MessageFrom +{ + /// + /// User information of the sender. + /// + [JsonPropertyName("user")] + public User? User { get; set; } +} + +/// +/// String constants for message importance levels. +/// +public static class MessagePayloadImportance +{ + /// + /// Normal importance. + /// + public const string Normal = "normal"; + + /// + /// High importance. + /// + public const string High = "high"; + + /// + /// Urgent importance. + /// + public const string Urgent = "urgent"; +} + +/// +/// Message body content. +/// +public class MessagePayloadBody +{ + /// + /// Type of content. Common values: "text", "html". + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// The content of the message. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } +} + +/// +/// Attachment in a message payload. +/// +public class MessagePayloadAttachment +{ + /// + /// Unique identifier for the attachment. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Type of attachment content. See for common values. + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// The attachment content. + /// + [JsonPropertyName("content")] + public object? Content { get; set; } +} + +/// +/// Reaction to a message. +/// +public class MessagePayloadReaction +{ + /// + /// Type of reaction + /// See for common values. + /// + [JsonPropertyName("reactionType")] + public string? ReactionType { get; set; } + + /// + /// Timestamp when the reaction was created. + /// + [JsonPropertyName("createdDateTime")] + public string? CreatedDateTime { get; set; } + + /// + /// User who reacted. + /// + [JsonPropertyName("user")] + public User? User { get; set; } +} + +/// +/// Represents a user who created a reaction. +/// +public class User +{ + /// + /// Gets or sets the user identifier. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the user identity type. + /// + [JsonPropertyName("userIdentityType")] + public string? UserIdentityType { get; set; } + + /// + /// Gets or sets the display name of the user. + /// + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } +} + +/// +/// String constants for user identity types. +/// +public static class UserIdentityTypes +{ + /// + /// Azure Active Directory user. + /// + public const string AadUser = "aadUser"; + + /// + /// On-premise Azure Active Directory user. + /// + public const string OnPremiseAadUser = "onPremiseAadUser"; + + /// + /// Anonymous guest user. + /// + public const string AnonymousGuest = "anonymousGuest"; + + /// + /// Federated user. + /// + public const string FederatedUser = "federatedUser"; +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionActionResponse.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionActionResponse.cs new file mode 100644 index 000000000..03b743ae1 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionActionResponse.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Handlers.TaskModules; + +namespace Microsoft.Teams.Apps.Handlers.MessageExtension; + +/// +/// Represents a response from a message extension action that can contain either a task module or compose extension response. +/// +public class MessageExtensionActionResponse +{ + /// + /// The task module result. + /// + [JsonPropertyName("task")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Response? Task { get; set; } + + /// + /// The compose extension result (for message extension results, auth, config, etc.). + /// + [JsonPropertyName("composeExtension")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ComposeExtension? ComposeExtension { get; set; } + + /// + /// Creates a new builder for MessageExtensionActionResponse. + /// + public static MessageExtensionActionResponseBuilder CreateBuilder() + { + return new MessageExtensionActionResponseBuilder(); + } +} + +/// +/// Builder for MessageExtensionActionResponse. +/// +public class MessageExtensionActionResponseBuilder +{ + private TaskModuleResponse? _taskResponse; + private MessageExtensionResponse? _extensionResponse; + + /// + /// Sets the task module response using a TaskModuleResponseBuilder. + /// + public MessageExtensionActionResponseBuilder WithTask(TaskModuleResponseBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + _taskResponse = builder.Validate(); + return this; + } + + /// + /// Sets the compose extension response using a MessageExtensionResponseBuilder. + /// + public MessageExtensionActionResponseBuilder WithComposeExtension(MessageExtensionResponseBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + _extensionResponse = builder.Validate(); + return this; + } + + /// + /// Validates and builds the MessageExtensionActionResponse. + /// + private MessageExtensionActionResponse Validate() + { + if (_taskResponse == null && _extensionResponse == null) + { + throw new InvalidOperationException("Either Task or ComposeExtension must be set. Use WithTask() or WithComposeExtension()."); + } + + if (_taskResponse != null && _extensionResponse != null) + { + throw new InvalidOperationException("Cannot set both Task and ComposeExtension. Use either WithTask() or WithComposeExtension(), not both."); + } + + return new MessageExtensionActionResponse + { + Task = _taskResponse?.Task, + ComposeExtension = _extensionResponse?.ComposeExtension + }; + } + + /// + /// Builds the MessageExtensionActionResponse and wraps it in an . + /// + /// The HTTP status code (default: 200). + public InvokeResponse Build(int statusCode = 200) + { + return new InvokeResponse(statusCode, Validate()); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionHandler.cs new file mode 100644 index 000000000..cf3dbf4ff --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionHandler.cs @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers.MessageExtension; + +/// +/// Delegate for handling message extension query invoke activities. +/// +public delegate Task> MessageExtensionQueryHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension submit action invoke activities. +/// Can return either a TaskModuleResponse or MessageExtensionResponse. +/// +public delegate Task> MessageExtensionSubmitActionHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension fetch task invoke activities. +/// Can return either a TaskModuleResponse or MessageExtensionResponse. +/// +public delegate Task> MessageExtensionFetchTaskHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension query link invoke activities. +/// +public delegate Task> MessageExtensionQueryLinkHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension anonymous query link invoke activities. +/// +public delegate Task> MessageExtensionAnonQueryLinkHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension select item invoke activities. +/// +public delegate Task> MessageExtensionSelectItemHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension query setting URL invoke activities. +/// +public delegate Task> MessageExtensionQuerySettingUrlHandler(Context> context, CancellationToken cancellationToken = default); + +/* +/// +/// Delegate for handling message extension card button clicked invoke activities. +/// +public delegate Task MessageExtensionCardButtonClickedHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Delegate for handling message extension setting invoke activities. +/// +public delegate Task MessageExtensionSettingHandler(Context> context, CancellationToken cancellationToken = default); +*/ + +/// +/// Extension methods for registering message extension invoke handlers. +/// +public static class MessageExtensionExtensions +{ + //TODO : add msg ext prefix to handlers ? very confusing right now as we have both onFetchTask and onTaskFetch. + //onSubmitAction is confusing as it is similar to adaptive cards + + /// + /// Registers a handler for message extension query invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnQuery(this TeamsBotApplication app, MessageExtensionQueryHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionQuery), + Selector = activity => activity.Name == InvokeNames.MessageExtensionQuery, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension submit action invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnSubmitAction(this TeamsBotApplication app, MessageExtensionSubmitActionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionSubmitAction), + Selector = activity => activity.Name == InvokeNames.MessageExtensionSubmitAction, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension query link invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnQueryLink(this TeamsBotApplication app, MessageExtensionQueryLinkHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionQueryLink), + Selector = activity => activity.Name == InvokeNames.MessageExtensionQueryLink, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension anonymous query link invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnAnonQueryLink(this TeamsBotApplication app, MessageExtensionAnonQueryLinkHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionAnonQueryLink), + Selector = activity => activity.Name == InvokeNames.MessageExtensionAnonQueryLink, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension fetch task invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnFetchTask(this TeamsBotApplication app, MessageExtensionFetchTaskHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionFetchTask), + Selector = activity => activity.Name == InvokeNames.MessageExtensionFetchTask, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension select item invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnSelectItem(this TeamsBotApplication app, MessageExtensionSelectItemHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionSelectItem), + Selector = activity => activity.Name == InvokeNames.MessageExtensionSelectItem, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension query setting URL invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnQuerySettingUrl(this TeamsBotApplication app, MessageExtensionQuerySettingUrlHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionQuerySettingUrl), + Selector = activity => activity.Name == InvokeNames.MessageExtensionQuerySettingUrl, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + + /* + /// + /// Registers a handler for message extension card button clicked invoke activities. + /// + public static TeamsBotApplication OnCardButtonClicked(this TeamsBotApplication app, MessageExtensionCardButtonClickedHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionCardButtonClicked), + Selector = activity => activity.Name == InvokeNames.MessageExtensionCardButtonClicked, + HandlerWithReturn = async (ctx, cancellationToken) => + { + var typedActivity = new InvokeActivity(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message extension setting invoke activities. + /// + public static TeamsBotApplication OnSetting(this TeamsBotApplication app, MessageExtensionSettingHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageExtensionSetting), + Selector = activity => activity.Name == InvokeNames.MessageExtensionSetting, + HandlerWithReturn = async (ctx, cancellationToken) => + { + var typedActivity = new InvokeActivity(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + */ +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionQuery.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionQuery.cs new file mode 100644 index 000000000..5d8112229 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionQuery.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers.MessageExtension; + +/// +/// Messaging extension query payload. +/// +public class MessageExtensionQuery +{ + /// + /// Id of the command assigned by the bot. + /// + [JsonPropertyName("commandId")] + public required string CommandId { get; set; } + + /// + /// Parameters for the query. + /// + [JsonPropertyName("parameters")] + public required IList Parameters { get; set; } + + /// + /// Query options for pagination. + /// + [JsonPropertyName("queryOptions")] + public QueryOptions? QueryOptions { get; set; } + + //TODO : check how to use this ? auth ? + /* + /// + /// State parameter passed back to the bot after authentication/configuration flow. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + */ +} + +/// +/// Query parameter. +/// +public class QueryParameter +{ + /// + /// Name of the parameter. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Value of the parameter. + /// + [JsonPropertyName("value")] + public required string Value { get; set; } +} + + +/// +/// Query options for pagination. +/// +public class QueryOptions +{ + /// + /// Number of entities to skip. + /// + [JsonPropertyName("skip")] + public int? Skip { get; set; } + + /// + /// Number of entities to fetch. + /// + [JsonPropertyName("count")] + public int? Count { get; set; } +} + diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionQueryLink.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionQueryLink.cs new file mode 100644 index 000000000..ed45471bc --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionQueryLink.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers.MessageExtension; + +/// +/// App-based query link payload for link unfurling. +/// +public class MessageExtensionQueryLink +{ + /// + /// URL queried by user. + /// + [JsonPropertyName("url")] + public Uri? Url { get; set; } + + //TODO : review + /* + /// + /// State parameter for OAuth flow. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + */ +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionResponse.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionResponse.cs new file mode 100644 index 000000000..71f156069 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageExtension/MessageExtensionResponse.cs @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers.MessageExtension; + +/// +/// Messaging extension response types. +/// +public static class MessageExtensionResponseType +{ + /// + /// Result type - displays a list of search results. + /// + public const string Result = "result"; + + /// + /// Message type - displays a plain text message. + /// + public const string Message = "message"; + + /// + /// Bot message preview type - shows a preview that can be edited before sending. + /// + public const string BotMessagePreview = "botMessagePreview"; + + /// + /// Config type - prompts the user to set up the message extension. + /// + public const string Config = "config"; + + //TODO : review + /* + /// + /// Auth type - prompts the user to authenticate. + /// + public const string Auth = "auth"; + */ +} + +/// +/// Messaging extension response wrapper. +/// +public class MessageExtensionResponse +{ + /// + /// The compose extension result (for message extension results, auth, config, etc.). + /// + [JsonPropertyName("composeExtension")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ComposeExtension? ComposeExtension { get; set; } + + /// + /// Creates a new builder for MessageExtensionResponse. + /// + public static MessageExtensionResponseBuilder CreateBuilder() + { + return new MessageExtensionResponseBuilder(); + } +} + + +/// +/// Messaging extension result. +/// +public class ComposeExtension +{ + /// + /// Type of result. + /// See for common values. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Layout for attachments. + /// See for common values. + /// + [JsonPropertyName("attachmentLayout")] + public string? AttachmentLayout { get; set; } + + /// + /// Array of attachments (cards) to display. + /// + // TODO : there is an extra preview field but when is it used ? + [JsonPropertyName("attachments")] + public IList? Attachments { get; set; } + + /// + /// Text to display. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Activity preview for bot message preview. + /// + //TODO : this needs to be activity type or something else - format is type, attachments[] + [JsonPropertyName("activityPreview")] + public TeamsActivity? ActivityPreview { get; set; } + + /// + /// Suggested actions for config type. + /// + [JsonPropertyName("suggestedActions")] + public MessageExtensionSuggestedAction? SuggestedActions { get; set; } +} + +/// +/// Suggested actions for messaging extension configuration. +/// +public class MessageExtensionSuggestedAction +{ + //TODO : this should come from cards package + + /// + /// Array of actions. + /// + [JsonPropertyName("actions")] + public IList? Actions { get; set; } +} + + +/// +/// Builder for MessageExtensionResponse. +/// +public class MessageExtensionResponseBuilder +{ + private string? _type; + private string? _attachmentLayout; + private TeamsAttachment[]? _attachments; + private TeamsActivity? _activityPreview; + private object[]? _suggestedActions; + private string? _text; + + /// + /// Sets the type of the response. Common values: "result", "auth", "config", "message", "botMessagePreview". + /// + public MessageExtensionResponseBuilder WithType(string type) + { + _type = type; + return this; + } + + /// + /// Sets the attachment layout. Common values: "list", "grid". + /// + public MessageExtensionResponseBuilder WithAttachmentLayout(string layout) + { + _attachmentLayout = layout; + return this; + } + + /// + /// Sets the attachments for the response. + /// + public MessageExtensionResponseBuilder WithAttachments(params TeamsAttachment[] attachments) + { + _attachments = attachments; + return this; + } + + /// + /// Sets the activity preview for bot message preview type. + /// + public MessageExtensionResponseBuilder WithActivityPreview(TeamsActivity activityPreview) + { + _activityPreview = activityPreview; + return this; + } + + /// + /// Sets suggested actions for config type. + /// + public MessageExtensionResponseBuilder WithSuggestedActions(params object[] actions) + { + _suggestedActions = actions; + return this; + } + + /// + /// Sets the text message for message type. + /// + public MessageExtensionResponseBuilder WithText(string text) + { + _text = text; + return this; + } + + /// + /// Validates and builds the MessageExtensionResponse. + /// + internal MessageExtensionResponse Validate() + { + if (string.IsNullOrEmpty(_type)) + { + throw new InvalidOperationException("Type must be set. Use WithType() to specify MessageExtensionResponseType.Result, Message, BotMessagePreview, or Config."); + } + + return _type switch + { + MessageExtensionResponseType.Result => ValidateResultType(), + MessageExtensionResponseType.Message => ValidateMessageType(), + MessageExtensionResponseType.BotMessagePreview => ValidateBotMessagePreviewType(), + MessageExtensionResponseType.Config => ValidateConfigType(), + _ => throw new InvalidOperationException($"Unknown message extension response type: {_type}") + }; + } + + private MessageExtensionResponse ValidateResultType() + { + if (_attachments == null || _attachments.Length == 0) + { + throw new InvalidOperationException("Attachments must be set for Result type. Use WithAttachments()."); + } + + if (!string.IsNullOrEmpty(_text)) + { + throw new InvalidOperationException("Text cannot be set for Result type. Text is only used with Message type."); + } + + if (_activityPreview != null) + { + throw new InvalidOperationException("ActivityPreview cannot be set for Result type. ActivityPreview is only used with BotMessagePreview type."); + } + + if (_suggestedActions != null) + { + throw new InvalidOperationException("SuggestedActions cannot be set for Result type. SuggestedActions is only used with Config type."); + } + + return new MessageExtensionResponse + { + ComposeExtension = new ComposeExtension + { + Type = _type, + AttachmentLayout = _attachmentLayout, + Attachments = _attachments + } + }; + } + + private MessageExtensionResponse ValidateMessageType() + { + if (string.IsNullOrEmpty(_text)) + { + throw new InvalidOperationException("Text must be set for Message type. Use WithText()."); + } + + if (_attachments != null) + { + throw new InvalidOperationException("Attachments cannot be set for Message type. Attachments is only used with Result or BotMessagePreview type."); + } + + if (!string.IsNullOrEmpty(_attachmentLayout)) + { + throw new InvalidOperationException("AttachmentLayout cannot be set for Message type. AttachmentLayout is only used with Result type."); + } + + if (_activityPreview != null) + { + throw new InvalidOperationException("ActivityPreview cannot be set for Message type. ActivityPreview is only used with BotMessagePreview type."); + } + + if (_suggestedActions != null) + { + throw new InvalidOperationException("SuggestedActions cannot be set for Message type. SuggestedActions is only used with Config type."); + } + + return new MessageExtensionResponse + { + ComposeExtension = new ComposeExtension + { + Type = _type, + Text = _text + } + }; + } + + private MessageExtensionResponse ValidateBotMessagePreviewType() + { + if (_activityPreview == null) + { + throw new InvalidOperationException("ActivityPreview must be set for BotMessagePreview type. Use WithActivityPreview()."); + } + + if (!string.IsNullOrEmpty(_text)) + { + throw new InvalidOperationException("Text cannot be set for BotMessagePreview type. Text is only used with Message type."); + } + + if (!string.IsNullOrEmpty(_attachmentLayout)) + { + throw new InvalidOperationException("AttachmentLayout cannot be set for BotMessagePreview type. AttachmentLayout is only used with Result type."); + } + + if (_suggestedActions != null) + { + throw new InvalidOperationException("SuggestedActions cannot be set for BotMessagePreview type. SuggestedActions is only used with Config type."); + } + + return new MessageExtensionResponse + { + ComposeExtension = new ComposeExtension + { + Type = _type, + ActivityPreview = _activityPreview, + Attachments = _attachments + } + }; + } + + private MessageExtensionResponse ValidateConfigType() + { + if (_suggestedActions == null || _suggestedActions.Length == 0) + { + throw new InvalidOperationException("SuggestedActions must be set for Config type. Use WithSuggestedActions()."); + } + + if (_attachments != null) + { + throw new InvalidOperationException("Attachments cannot be set for Config type. Attachments is only used with Result or BotMessagePreview type."); + } + + if (!string.IsNullOrEmpty(_attachmentLayout)) + { + throw new InvalidOperationException("AttachmentLayout cannot be set for Config type. AttachmentLayout is only used with Result type."); + } + + if (!string.IsNullOrEmpty(_text)) + { + throw new InvalidOperationException("Text cannot be set for Config type. Text is only used with Message type."); + } + + if (_activityPreview != null) + { + throw new InvalidOperationException("ActivityPreview cannot be set for Config type. ActivityPreview is only used with BotMessagePreview type."); + } + + return new MessageExtensionResponse + { + ComposeExtension = new ComposeExtension + { + Type = _type, + SuggestedActions = new MessageExtensionSuggestedAction { Actions = _suggestedActions } + } + }; + } + + /// + /// Builds the MessageExtensionResponse and wraps it in an . + /// + /// The HTTP status code (default: 200). + public InvokeResponse Build(int statusCode = 200) + { + return new InvokeResponse(statusCode, Validate()); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageHandler.cs new file mode 100644 index 000000000..6f651ec65 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageHandler.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling message activities. +/// +/// The context for the message activity. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task MessageHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message activity handlers. +/// +public static class MessageExtensions +{ + /// + /// Registers a handler for message activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessage(this TeamsBotApplication app, MessageHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + + Name = TeamsActivityType.Message, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message activities matching the specified pattern. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The regex pattern to match against the message text. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessage(this TeamsBotApplication app, string pattern, MessageHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + Regex regex = new(pattern); + + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.Message, pattern]), + Selector = msg => regex.IsMatch(msg.TextWithoutMentions ?? ""), + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message activities matching the specified regex. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The regex to match against the message text. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessage(this TeamsBotApplication app, Regex regex, MessageHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + ArgumentNullException.ThrowIfNull(regex, nameof(regex)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.Message, regex.ToString()]), + Selector = msg => regex.IsMatch(msg.TextWithoutMentions ?? ""), + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} + diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageReactionHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageReactionHandler.Activity.cs new file mode 100644 index 000000000..b9a7945a2 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageReactionHandler.Activity.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Represents a message reaction activity. +/// +public class MessageReactionActivity : TeamsActivity +{ + /// + /// Convenience method to create a MessageReactionActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A MessageReactionActivity instance. + public static new MessageReactionActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new MessageReactionActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public MessageReactionActivity() : base(TeamsActivityType.MessageReaction) + { + } + + /// + /// Internal constructor to create MessageReactionActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected MessageReactionActivity(CoreActivity activity) : base(activity) + { + ArgumentNullException.ThrowIfNull(activity); + ReactionsAdded = activity.Properties.Extract>("reactionsAdded"); + ReactionsRemoved = activity.Properties.Extract>("reactionsRemoved"); + ReplyToId = activity.Properties.Extract("replyToId"); + } + + /// + /// Gets or sets the reactions added to the message. + /// + [JsonPropertyName("reactionsAdded")] + public IList? ReactionsAdded { get; set; } + + /// + /// Gets or sets the reactions removed from the message. + /// + [JsonPropertyName("reactionsRemoved")] + public IList? ReactionsRemoved { get; set; } +} + +/// +/// Represents a reaction to a message. +/// +public class MessageReaction +{ + /// + /// Gets or sets the type of reaction. + /// See for common values. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } +} + +/// +/// String constants for reaction types. +/// +public static class ReactionTypes +{ + /// + /// Like reaction (👍). + /// + public const string Like = "like"; + + /// + /// Heart reaction (❤️). + /// + public const string Heart = "heart"; + + /// + /// Checkmark reaction (✅). + /// + public const string Checkmark = "checkmark"; + + /// + /// Hourglass reaction (⏳). + /// + public const string Hourglass = "hourglass"; + + /// + /// Pushpin reaction (📌). + /// + public const string Pushpin = "pushpin"; + + /// + /// Exclamation reaction (❗). + /// + public const string Exclamation = "exclamation"; + + /// + /// Laugh reaction (😆). + /// + public const string Laugh = "laugh"; + + /// + /// Surprise reaction (😮). + /// + public const string Surprise = "surprise"; + + /// + /// Sad reaction (🙁). + /// + public const string Sad = "sad"; + + /// + /// Angry reaction (😠). + /// + public const string Angry = "angry"; +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageReactionHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageReactionHandler.cs new file mode 100644 index 000000000..132325bf2 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageReactionHandler.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling message reaction activities. +/// +/// The context for the message reaction activity. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task MessageReactionHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message reaction activity handlers. +/// +public static class MessageReactionExtensions +{ + /// + /// Registers a handler for message reaction activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessageReaction(this TeamsBotApplication app, MessageReactionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.MessageReaction, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message reaction activities where reactions were added. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessageReactionAdded(this TeamsBotApplication app, MessageReactionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.MessageReaction, "reactionsAdded"]), + Selector = activity => activity.ReactionsAdded?.Count > 0, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } + + /// + /// Registers a handler for message reaction activities where reactions were removed. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessageReactionRemoved(this TeamsBotApplication app, MessageReactionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", [TeamsActivityType.MessageReaction, "reactionsRemoved"]), + Selector = activity => activity.ReactionsRemoved?.Count > 0, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.Value.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.Value.cs new file mode 100644 index 000000000..0238ed2e7 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.Value.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Defines the structure that arrives in the Activity.Value for an Invoke activity with +/// Name of 'message/submitAction'. +/// +public class SubmitActionValue +{ + /// + /// The name of the action that was submitted. + /// + [JsonPropertyName("actionName")] + public required string ActionName { get; set; } + + /// + /// The data submitted with the action. + /// + [JsonPropertyName("actionValue")] + public JsonNode? ActionValue { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.cs new file mode 100644 index 000000000..625e3f01c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageSubmitActionHandler.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling message submit action invoke activities. +/// +/// The context for the invoke activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task that represents the asynchronous operation. The task result contains the invoke response. +public delegate Task MessageSubmitActionHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message submit action invoke handlers. +/// +public static class MessageSubmitActionExtensions +{ + /// + /// Registers a handler for message submit action invoke activities. + /// Cannot be combined with . + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessageSubmitAction(this TeamsBotApplication app, MessageSubmitActionHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.MessageSubmitAction), + Selector = activity => activity.Name == InvokeNames.MessageSubmitAction, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageUpdateHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageUpdateHandler.Activity.cs new file mode 100644 index 000000000..255e43b24 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageUpdateHandler.Activity.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Represents a message update activity. +/// +public class MessageUpdateActivity : MessageActivity +{ + /// + /// Convenience method to create a MessageUpdateActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A MessageUpdateActivity instance. + public static new MessageUpdateActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new MessageUpdateActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public MessageUpdateActivity() : base() + { + Type = TeamsActivityType.MessageUpdate; + } + + /// + /// Initializes a new instance of the class with the specified text. + /// + /// The text content of the message. + public MessageUpdateActivity(string text) : base(text) + { + Type = TeamsActivityType.MessageUpdate; + } + + /// + /// Internal constructor to create MessageUpdateActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected MessageUpdateActivity(CoreActivity activity) : base(activity) + { + Type = TeamsActivityType.MessageUpdate; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/MessageUpdateHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/MessageUpdateHandler.cs new file mode 100644 index 000000000..fafabecb7 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/MessageUpdateHandler.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling message update activities. +/// +/// The context for the message update activity. +/// A cancellation token that can be used to cancel the operation. +/// A task representing the asynchronous operation. +public delegate Task MessageUpdateHandler(Context context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering message update activity handlers. +/// +public static class MessageUpdateExtensions +{ + /// + /// Registers a handler for message update activities. + /// + /// + /// Breaking change: previously only the first matching handler was invoked. All matching handlers are now invoked sequentially. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnMessageUpdate(this TeamsBotApplication app, MessageUpdateHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = TeamsActivityType.MessageUpdate, + Selector = _ => true, + Handler = async (ctx, cancellationToken) => + { + await handler(ctx, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/TaskHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/TaskHandler.cs new file mode 100644 index 000000000..cb79e22cd --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/TaskHandler.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Handlers.TaskModules; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling task module invoke activities. +/// +/// The context for the invoke activity, providing access to the activity data and bot application. +/// A cancellation token that can be used to cancel the operation. +/// A task that represents the asynchronous operation. The task result contains the invoke response. +public delegate Task> TaskModuleHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering task module invoke handlers. +/// +public static class TaskExtensions +{ + + /// + /// Registers a handler for task module fetch invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTaskFetch(this TeamsBotApplication app, TaskModuleHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.TaskFetch), + Selector = activity => activity.Name == InvokeNames.TaskFetch, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); ; + } + }); + + return app; + } + + /// + /// Registers a handler for task module submit invoke activities. + /// Cannot be combined with . + /// + /// + /// Breaking change: previously a catch-all invoke handler could be registered alongside specific invoke handlers. This combination now throws at registration time. + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnTaskSubmit(this TeamsBotApplication app, TaskModuleHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.TaskSubmit), + Selector = activity => activity.Name == InvokeNames.TaskSubmit, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + Context> typedContext = new(ctx.TeamsBotApplication, typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/TaskModules/TaskModuleRequest.cs b/core/src/Microsoft.Teams.Apps/Handlers/TaskModules/TaskModuleRequest.cs new file mode 100644 index 000000000..5d51c1c33 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/TaskModules/TaskModuleRequest.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers.TaskModules; + +/// +/// Task module invoke request value payload. +/// +public class TaskModuleRequest +{ + /// + /// User input data. Free payload with key-value pairs. + /// + [JsonPropertyName("data")] + public object? Data { get; set; } + + /// + /// Current user context, i.e., the current theme. + /// + [JsonPropertyName("context")] + public TaskModuleRequestContext? Context { get; set; } +} + +/// +/// Current user context, i.e., the current theme. +/// +public class TaskModuleRequestContext +{ + /// + /// The user's current theme. + /// + [JsonPropertyName("theme")] + public string? Theme { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/TaskModules/TaskModuleResponse.cs b/core/src/Microsoft.Teams.Apps/Handlers/TaskModules/TaskModuleResponse.cs new file mode 100644 index 000000000..9ebf2dba0 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/TaskModules/TaskModuleResponse.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Handlers.TaskModules; + +/// +/// Task module response types. +/// +public static class TaskModuleResponseType +{ + /// + /// Continue type - displays a card or URL in the task module. + /// + public const string Continue = "continue"; + + /// + /// Message type - displays a plain text message. + /// + public const string Message = "message"; +} + +/// +/// Task module size constants. +/// +public static class TaskModuleSize +{ + /// + /// Small size. + /// + public const string Small = "small"; + + /// + /// Medium size. + /// + public const string Medium = "medium"; + + /// + /// Large size. + /// + public const string Large = "large"; +} + +/// +/// Task module response wrapper. +/// +public class TaskModuleResponse +{ + /// + /// The task module result. + /// + [JsonPropertyName("task")] + public Response? Task { get; set; } + + /// + /// Creates a new builder for TaskModuleResponse. + /// + public static TaskModuleResponseBuilder CreateBuilder() + { + return new TaskModuleResponseBuilder(); + } +} + +/// +/// Builder for TaskModuleResponse. +/// +public class TaskModuleResponseBuilder +{ + private string? _type; + private string? _title; + private object? _card; + private object _height = TaskModuleSize.Small; + private object _width = TaskModuleSize.Small; + private string? _message; + //private string? _url; + //private string? _fallbackUrl; + //private string? _completionBotId; + + /// + /// Sets the type of the response. Use TaskModuleResponseType constants. + /// + public TaskModuleResponseBuilder WithType(string type) + { + _type = type; + return this; + } + + /// + /// Sets the title of the task module. + /// + public TaskModuleResponseBuilder WithTitle(string title) + { + _title = title; + return this; + } + + /// + /// Sets the card content for continue type. + /// + public TaskModuleResponseBuilder WithCard(object card) + { + _card = card; + return this; + } + + /// + /// Sets the height. Can be a number (pixels) or use TaskModuleSize constants. + /// + public TaskModuleResponseBuilder WithHeight(object height) + { + _height = height; + return this; + } + + /// + /// Sets the width. Can be a number (pixels) or use TaskModuleSize constants. + /// + public TaskModuleResponseBuilder WithWidth(object width) + { + _width = width; + return this; + } + + /// + /// Sets the message for message type. + /// + public TaskModuleResponseBuilder WithMessage(string message) + { + _message = message; + return this; + } + + /* + /// + /// Sets the URL for continue type. + /// + public TaskModuleResponseBuilder WithUrl(string url) + { + _url = url; + return this; + } + + /// + /// Sets the fallback URL if the card cannot be displayed. + /// + public TaskModuleResponseBuilder WithFallbackUrl(string fallbackUrl) + { + _fallbackUrl = fallbackUrl; + return this; + } + + /// + /// Sets the completion bot ID. + /// + public TaskModuleResponseBuilder WithCompletionBotId(string completionBotId) + { + _completionBotId = completionBotId; + return this; + } + */ + + /// + /// Builds the TaskModuleResponse. + /// + internal TaskModuleResponse Validate() + { + if (string.IsNullOrEmpty(_type)) + { + throw new InvalidOperationException("Type must be set. Use WithType() to specify TaskModuleResponseType.Continue or TaskModuleResponseType.Message."); + } + + object? value = _type switch + { + TaskModuleResponseType.Continue => ValidateContinueType(), + TaskModuleResponseType.Message => ValidateMessageType(), + _ => throw new InvalidOperationException($"Unknown task module response type: {_type}") + }; + + return new TaskModuleResponse + { + Task = new Response + { + Type = _type, + Value = value + } + }; + } + + private object ValidateContinueType() + { + if (_card == null) + { + throw new InvalidOperationException("Card must be set for Continue type. Use WithCard()."); + } + + if (!string.IsNullOrEmpty(_message)) + { + throw new InvalidOperationException("Message cannot be set for Continue type. Message is only used with Message type."); + } + + return new + { + title = _title, + height = _height, + width = _width, + card = _card, + //url = _url, + //fallbackUrl = _fallbackUrl, + //completionBotId = _completionBotId + }; + } + + private string ValidateMessageType() + { + if (string.IsNullOrEmpty(_message)) + { + throw new InvalidOperationException("Message must be set for Message type. Use WithMessage()."); + } + + if (!string.IsNullOrEmpty(_title)) + { + throw new InvalidOperationException("Title cannot be set for Message type. Title is only used with Continue type."); + } + + if (_card != null) + { + throw new InvalidOperationException("Card cannot be set for Message type. Card is only used with Continue type."); + } + + return _message; + } + + /// + /// Builds the TaskModuleResponse and wraps it in an . + /// + /// The HTTP status code (default: 200). + public InvokeResponse Build(int statusCode = 200) + { + return new InvokeResponse(statusCode, Validate()); + } +} + +/// +/// Task module result. +/// +public class Response +{ + /// + /// Type of result. + /// + [JsonPropertyName("type")] + public required string Type { get; set; } + + /// + /// The result value. + /// + [JsonPropertyName("value")] + public object? Value { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Microsoft.Teams.Apps.csproj b/core/src/Microsoft.Teams.Apps/Microsoft.Teams.Apps.csproj new file mode 100644 index 000000000..9edc3f963 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Microsoft.Teams.Apps.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net10.0 + enable + enable + $(NoWarn);ExperimentalTeamsQuotedReplies + + + + + + + + + + + diff --git a/core/src/Microsoft.Teams.Apps/OAuth/OAuthCard.cs b/core/src/Microsoft.Teams.Apps/OAuth/OAuthCard.cs new file mode 100644 index 000000000..906ab8d53 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/OAuth/OAuthCard.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; + +namespace Microsoft.Teams.Apps.OAuth; + +/// +/// Represents an OAuthCard used to initiate an OAuth sign-in flow. +/// +public class OAuthCard +{ + /// + /// The text displayed on the card. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// The OAuth connection name configured on the bot. + /// + [JsonPropertyName("connectionName")] + public string? ConnectionName { get; set; } + + /// + /// The sign-in action buttons. + /// + [JsonPropertyName("buttons")] + public IList? Buttons { get; set; } + + /// + /// The token exchange resource for SSO. + /// When present, the Teams client attempts a silent token exchange before showing the sign-in button. + /// + [JsonPropertyName("tokenExchangeResource")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TokenExchangeResource? TokenExchangeResource { get; set; } + + /// + /// The token post resource for posting the token back after sign-in. + /// + [JsonPropertyName("tokenPostResource")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TokenPostResource? TokenPostResource { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/OAuth/OAuthFlow.cs b/core/src/Microsoft.Teams.Apps/OAuth/OAuthFlow.cs new file mode 100644 index 000000000..5e37d283c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/OAuth/OAuthFlow.cs @@ -0,0 +1,467 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; + +namespace Microsoft.Teams.Apps.OAuth; + +/// +/// Delegate invoked after a successful OAuth token exchange or sign-in verification. +/// +/// The activity context (invoke context from SSO or verifyState). +/// The token result containing the access token and connection name. +/// A cancellation token. +public delegate Task SignInCompleteHandler(Context context, GetTokenResult tokenResponse, CancellationToken cancellationToken); + +/// +/// Delegate invoked when an OAuth token exchange or sign-in verification fails. +/// +/// The activity context. +/// Optional failure details. Non-null when the failure originates from a Teams client-side +/// signin/failure invoke (contains the structured failure code and message). +/// Null when the failure is a server-side token exchange or verify-state failure. +/// A cancellation token. +public delegate Task SignInFailureHandler(Context context, SignInFailureValue? failure, CancellationToken cancellationToken); + +/// +/// Provides a high-level abstraction for Teams Bot SSO authentication. +/// Encapsulates silent token acquisition, SSO token exchange, fallback sign-in, and sign-out. +/// +public class OAuthFlow +{ + private readonly TeamsBotApplication _app; + private readonly ILogger _logger; + private readonly string _connectionName; + private readonly OAuthOptions _defaultOptions; + private SignInCompleteHandler? _onSignInComplete; + private SignInFailureHandler? _onSignInFailure; + + // Deduplication cache for signin/tokenExchange invoke activities. + // Teams may send duplicates from multiple endpoints (mobile, desktop, web). + private readonly ConcurrentDictionary _processedExchanges = new(); + + // Tracks users with a pending sign-in (OAuthCard sent, waiting for tokenExchange/verifyState/failure). + // Used to scope signin/failure notifications to flows that actually initiated a sign-in. + private readonly ConcurrentDictionary _pendingSignIns = new(); + + internal OAuthFlow(TeamsBotApplication app, string connectionName, OAuthOptions options, ILogger logger) + { + _app = app; + _connectionName = connectionName; + _defaultOptions = options; + _logger = logger; + } + + /// + /// The OAuth connection name. + /// + public string ConnectionName => _connectionName; + + /// + /// Register a callback invoked after a successful token exchange (SSO or fallback sign-in). + /// + /// The handler to invoke on successful sign-in. + /// This instance for chaining. + public OAuthFlow OnSignInComplete(SignInCompleteHandler handler) + { + _onSignInComplete = handler; + return this; + } + + /// + /// Register a callback invoked when token exchange fails. + /// + /// The handler to invoke on sign-in failure. + /// This instance for chaining. + public OAuthFlow OnSignInFailure(SignInFailureHandler handler) + { + _onSignInFailure = handler; + return this; + } + + /// + /// Attempt silent token acquisition from the Bot Framework Token Store. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + /// The access token string, or null if no token is cached. + public async Task GetTokenAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + GetTokenResult? result = await _app.UserTokenClient.GetTokenAsync(userId, _connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); + return result?.Token; + } + + /// + /// Attempt silent token acquisition; if no token is available, send an OAuthCard to initiate the SSO flow. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + /// The token if already cached, or null if SSO was initiated (the result will arrive via ). + public Task SignInAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + => SignInAsync(context, options: null, cancellationToken); + + /// + /// Attempt silent token acquisition; if no token is available, send an OAuthCard to initiate the SSO flow. + /// + /// The activity type. + /// The current turn context. + /// OAuth options for customizing the sign-in card text. + /// A cancellation token. + /// The token if already cached, or null if SSO was initiated (the result will arrive via ). + public async Task SignInAsync(Context context, OAuthOptions? options, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + options ??= _defaultOptions; + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + // 1. Try silent token acquisition + GetTokenResult? existingToken = await _app.UserTokenClient.GetTokenAsync(userId, _connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); + if (existingToken?.Token is not null) + { + _logger.LogDebug("Token found in store for connection '{ConnectionName}', user '{UserId}'.", _connectionName, userId); + return existingToken.Token; + } + + // 2. No token - get sign-in resource and send OAuthCard + _logger.LogDebug("No cached token for connection '{ConnectionName}'. Initiating sign-in flow.", _connectionName); + + // Build state with MsAppId so the Token Service returns TokenExchangeResource for SSO + var tokenExchangeState = new + { + ConnectionName = _connectionName, + Conversation = new + { + ActivityId = context.Activity.Id, + Bot = new { Id = context.Activity.Recipient?.Id }, + ChannelId = channelId, + Conversation = new { Id = context.Activity.Conversation?.Id }, + ServiceUrl = context.Activity.ServiceUrl?.ToString(), + User = new { Id = userId } + }, + MsAppId = _app.AppId + }; + string state = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(tokenExchangeState)); + + GetSignInResourceResult signInResource = await _app.UserTokenClient + .GetSignInResourceAsync(state, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + OAuthCard oauthCard = new() + { + Text = options.OAuthCardText, + ConnectionName = _connectionName, + Buttons = + [ + new SuggestedAction(ActionType.SignIn, options.SignInButtonText) { Value = signInResource.SignInLink } + ], + TokenExchangeResource = signInResource.TokenExchangeResource, + TokenPostResource = signInResource.TokenPostResource + }; + + // Serialize to JsonElement so the source-generated JSON context can handle it + JsonElement oauthCardJson = JsonSerializer.SerializeToElement(oauthCard); + + TeamsAttachment attachment = TeamsAttachment.CreateBuilder() + .WithContentType(AttachmentContentType.OAuthCard) + .WithContent(oauthCardJson) + .Build(); + + TeamsActivity oauthActivity = TeamsActivity.CreateBuilder() + .WithConversationReference(context.Activity) + .WithRecipient(context.Activity.From, false) + .WithAttachment(attachment) + .Build(); + + await context.SendActivityAsync(oauthActivity, cancellationToken).ConfigureAwait(false); + + // Track that this user has a pending sign-in for this flow + _pendingSignIns[userId] = DateTimeOffset.UtcNow; + + return null; + } + + /// + /// Sign the user out, revoking their token from the Bot Framework Token Store. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + public async Task SignOutAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + _logger.LogDebug("Signing out user '{UserId}' from connection '{ConnectionName}'.", userId, _connectionName); + await _app.UserTokenClient.SignOutUserAsync(userId, _connectionName, channelId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Check whether the user has a valid cached token for this flow's connection. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + /// True if the user has a valid token; false otherwise. + public async Task IsSignedInAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + string? token = await GetTokenAsync(context, cancellationToken).ConfigureAwait(false); + return token is not null; + } + + /// + /// Get the token status for all configured OAuth connections. + /// This calls GetTokenStatus which returns every connection registered on the bot, + /// so the developer never needs to enumerate connection names manually. + /// + /// The activity type. + /// The current turn context. + /// A cancellation token. + /// A list of token status results for all configured connections. + public async Task> GetConnectionStatusAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + string userId = GetUserId(context); + string channelId = GetChannelId(context); + + return await _app.UserTokenClient.GetTokenStatusAsync(userId, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Handles the signin/tokenExchange invoke activity. + /// + internal async Task HandleTokenExchangeAsync(Context context, SignInTokenExchangeValue exchangeValue, CancellationToken cancellationToken) + { + string exchangeId = exchangeValue.Id ?? string.Empty; + + // Deduplication: Teams sends duplicate exchanges from multiple endpoints + if (!_processedExchanges.TryAdd(exchangeId, DateTimeOffset.UtcNow)) + { + _logger.LogDebug("Duplicate signin/tokenExchange with Id '{ExchangeId}' - returning 200 no-op.", exchangeId); + return new InvokeResponse(200); + } + + CleanupExpiredEntries(); + + string userId = GetUserId(context); + string channelId = GetChannelId(context); + string connectionName = exchangeValue.ConnectionName ?? _connectionName; + + try + { + GetTokenResult tokenResult = await _app.UserTokenClient + .ExchangeTokenAsync(userId, connectionName, channelId, exchangeValue.Token, cancellationToken) + .ConfigureAwait(false); + + if (tokenResult?.Token is not null) + { + _pendingSignIns.TryRemove(userId, out _); + _logger.LogDebug("Token exchange succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + if (_onSignInComplete is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); + } + return new InvokeResponse(200); + } + } + catch (HttpRequestException ex) + { + _pendingSignIns.TryRemove(userId, out _); + _logger.LogWarning(ex, "Token exchange failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + return await HandleTokenExchangeFailureAsync(context, exchangeValue, ex.StatusCode, ex.Message, cancellationToken).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + _pendingSignIns.TryRemove(userId, out _); + _logger.LogWarning(ex, "Token exchange failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + return await HandleTokenExchangeFailureAsync(context, exchangeValue, null, ex.Message, cancellationToken).ConfigureAwait(false); + } + + // Token was null without exception — treat as expected failure + _pendingSignIns.TryRemove(userId, out _); + return await HandleTokenExchangeFailureAsync(context, exchangeValue, null, "Token exchange returned null token.", cancellationToken).ConfigureAwait(false); + } + + private async Task HandleTokenExchangeFailureAsync( + Context context, + SignInTokenExchangeValue exchangeValue, + System.Net.HttpStatusCode? statusCode, + string? failureDetail, + CancellationToken cancellationToken) + { + if (_onSignInFailure is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false); + } + + // For unexpected status codes (e.g., 401 Unauthorized, 403 Forbidden), + // return the original status code so the caller can distinguish the failure. + if (statusCode.HasValue + && statusCode.Value != System.Net.HttpStatusCode.NotFound + && statusCode.Value != System.Net.HttpStatusCode.BadRequest + && statusCode.Value != System.Net.HttpStatusCode.PreconditionFailed) + { + return new InvokeResponse((int)statusCode.Value); + } + + // 412 tells Teams to show the sign-in card as fallback. + // Include a response body with the exchange ID and failure detail for diagnostics. + return new InvokeResponse(412, new TokenExchangeInvokeResponse + { + Id = exchangeValue.Id, + ConnectionName = exchangeValue.ConnectionName, + FailureDetail = failureDetail + }); + } + + /// + /// Handles the signin/verifyState invoke activity. + /// + internal async Task HandleVerifyStateAsync(Context context, SignInVerifyStateValue verifyValue, CancellationToken cancellationToken) + { + if (verifyValue.State is null) + { + _logger.LogWarning( + "Verify state: state parameter is null for conversation '{ConversationId}', user '{UserId}'.", + context.Activity.Conversation?.Id, + context.Activity.From?.Id); + return new InvokeResponse(404); + } + + string userId = GetUserId(context); + string channelId = GetChannelId(context); + string connectionName = _connectionName; + + try + { + GetTokenResult? tokenResult = await _app.UserTokenClient + .GetTokenAsync(userId, connectionName, channelId, code: verifyValue.State, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (tokenResult?.Token is not null) + { + _pendingSignIns.TryRemove(userId, out _); + _logger.LogDebug("Verify state succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + if (_onSignInComplete is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); + } + return new InvokeResponse(200); + } + } + catch (HttpRequestException ex) + { + _pendingSignIns.TryRemove(userId, out _); + _logger.LogWarning(ex, "Verify state failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); + + if (_onSignInFailure is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false); + } + + // For unexpected status codes, return the original code + if (ex.StatusCode.HasValue + && ex.StatusCode.Value != System.Net.HttpStatusCode.NotFound + && ex.StatusCode.Value != System.Net.HttpStatusCode.BadRequest + && ex.StatusCode.Value != System.Net.HttpStatusCode.PreconditionFailed) + { + return new InvokeResponse((int)ex.StatusCode.Value); + } + + // 412 tells Teams to fall back to the sign-in card + return new InvokeResponse(412); + } + + // No token returned — the code likely belongs to a different connection. + // Do NOT fire OnSignInFailure or clear pending state; the verifyState loop + // in OAuthFlowExtensions will try the next registered flow. + _logger.LogDebug("Verify state: no token for connection '{ConnectionName}', user '{UserId}'. Code may belong to another connection.", connectionName, userId); + return new InvokeResponse(412); + } + + /// + /// Whether this flow has a pending sign-in for the given user. + /// Used to scope signin/failure notifications to flows that initiated a sign-in. + /// + /// + /// Best-effort: in multi-instance deployments the OAuthCard may have been sent by a different instance, + /// so this check may return false even when a sign-in is active. Callers should fall back + /// to notifying all flows when no flow reports a pending sign-in. + /// + internal bool HasPendingSignIn(string userId) + { + return _pendingSignIns.ContainsKey(userId); + } + + /// + /// Handles the signin/failure invoke activity sent by the Teams client when SSO fails client-side. + /// + internal async Task HandleSignInFailureAsync(Context context, SignInFailureValue failureValue, CancellationToken cancellationToken) + { + string? userId = context.Activity.From?.Id; + if (userId is not null) + { + _pendingSignIns.TryRemove(userId, out _); + } + + _logger.LogWarning( + "Sign-in failed for user '{UserId}' in conversation '{ConversationId}': {FailureCode} — {FailureMessage}.{Guidance}", + userId, + context.Activity.Conversation?.Id, + failureValue.Code, + failureValue.Message, + string.Equals(failureValue.Code, "resourcematchfailed", StringComparison.OrdinalIgnoreCase) + ? " Verify that your Entra app registration has 'Expose an API' configured with the correct Application ID URI matching your OAuth connection's Token Exchange URL." + : string.Empty); + + if (_onSignInFailure is not null) + { + Context baseContext = new(context.TeamsBotApplication, context.Activity); + await _onSignInFailure(baseContext, failureValue, cancellationToken).ConfigureAwait(false); + } + + return new InvokeResponse(200); + } + + private void CleanupExpiredEntries() + { + DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddMinutes(-5); + foreach (KeyValuePair kvp in _processedExchanges) + { + if (kvp.Value < cutoff) + { + _processedExchanges.TryRemove(kvp.Key, out _); + } + } + foreach (KeyValuePair kvp in _pendingSignIns) + { + if (kvp.Value < cutoff) + { + _pendingSignIns.TryRemove(kvp.Key, out _); + } + } + } + + private static string GetUserId(Context context) where TActivity : TeamsActivity + => context.Activity.From?.Id ?? throw new InvalidOperationException("Activity.From.Id is required for OAuth operations."); + + private static string GetChannelId(Context context) where TActivity : TeamsActivity + => context.Activity.ChannelId ?? throw new InvalidOperationException("Activity.ChannelId is required for OAuth operations."); + +} diff --git a/core/src/Microsoft.Teams.Apps/OAuth/OAuthFlowExtensions.cs b/core/src/Microsoft.Teams.Apps/OAuth/OAuthFlowExtensions.cs new file mode 100644 index 000000000..cc7445fb8 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/OAuth/OAuthFlowExtensions.cs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.OAuth; + +/// +/// Extension methods for registering instances on a . +/// +public static class OAuthFlowExtensions +{ + + /// + /// Register an with an explicit OAuth connection name. + /// + /// The Teams bot application. + /// The OAuth connection name configured on the bot. + /// The instance for configuring callbacks. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName) + => AddOAuthFlow(app, new OAuthOptions { ConnectionName = connectionName }); + + /// + /// Register an with that configure both the + /// connection name and the default OAuthCard text shown during sign-in. + /// Per-call options passed to + /// override these defaults. + /// + /// The Teams bot application. + /// OAuth options. is required. + /// The instance for configuring callbacks. + public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, OAuthOptions options) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ConnectionName, nameof(options.ConnectionName)); + + string connectionName = options.ConnectionName; + OAuthFlowRegistry registry = GetOrCreateRegistry(app); + ILogger logger = GetLogger(app); + + OAuthFlow flow = new(app, connectionName, options, logger); + registry.Register(connectionName, flow); + + return flow; + } + + private static OAuthFlowRegistry GetOrCreateRegistry(TeamsBotApplication app) + { + if (app.OAuthRegistry is not null) + { + return app.OAuthRegistry; + } + + OAuthFlowRegistry registry = new(); + app.OAuthRegistry = registry; + + // Register shared routes once per app + RegisterRoutes(app, registry); + return registry; + } + + private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry registry) + { + // signin/tokenExchange + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInTokenExchange), + Selector = activity => activity.Name == InvokeNames.SignInTokenExchange, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + SignInTokenExchangeValue? exchangeValue = typedActivity.Value; + + if (exchangeValue is null) + { + return new InvokeResponse(400); + } + + OAuthFlow? flow = registry.Resolve(exchangeValue.ConnectionName); + if (flow is null) + { + return new InvokeResponse(400); + } + + return await flow.HandleTokenExchangeAsync(ctx, exchangeValue, cancellationToken).ConfigureAwait(false); + } + }); + + // signin/failure - Teams client-side SSO failure notification + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInFailure), + Selector = activity => activity.Name == InvokeNames.SignInFailure, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + SignInFailureValue failureValue = typedActivity.Value ?? new SignInFailureValue(); + string? userId = ctx.Activity.From?.Id; + + // signin/failure doesn't carry a connection name. + // Scope to flows that have an active sign-in for this user; + // fall back to all flows if none report a pending sign-in + // (e.g., multi-instance deployment where the OAuthCard was sent by another node). + IEnumerable allFlows = registry.GetAllFlows(); + List activeFlows = userId is not null + ? allFlows.Where(f => f.HasPendingSignIn(userId)).ToList() + : []; + IEnumerable targetFlows = activeFlows.Count > 0 ? activeFlows : allFlows; + + foreach (OAuthFlow flow in targetFlows) + { + await flow.HandleSignInFailureAsync(ctx, failureValue, cancellationToken).ConfigureAwait(false); + } + + return new InvokeResponse(200); + } + }); + + // signin/verifyState + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInVerifyState), + Selector = activity => activity.Name == InvokeNames.SignInVerifyState, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + SignInVerifyStateValue? verifyValue = typedActivity.Value; + + if (verifyValue is null) + { + return new InvokeResponse(404); + } + + // verifyState doesn't carry a connection name, so try each registered flow + foreach (OAuthFlow flow in registry.GetAllFlows()) + { + InvokeResponse response = await flow.HandleVerifyStateAsync(ctx, verifyValue, cancellationToken).ConfigureAwait(false); + if (response.Status == 200) + { + return response; + } + } + + return new InvokeResponse(400); + } + }); + } + + private static NullLogger GetLogger(TeamsBotApplication app) + { + _ = app; // Reserved for future use (e.g., resolving ILoggerFactory from DI) + return NullLogger.Instance; + } +} + +/// +/// Internal registry that maps connection names to instances. +/// Handles multi-connection dispatch for shared invoke routes. +/// +internal sealed class OAuthFlowRegistry +{ + private readonly Dictionary _flows = new(StringComparer.OrdinalIgnoreCase); + + internal void Register(string connectionName, OAuthFlow flow) + { + if (!_flows.TryAdd(connectionName, flow)) + { + throw new InvalidOperationException($"An OAuthFlow is already registered for connection '{connectionName}'."); + } + } + + /// + /// Resolve the OAuthFlow for a given connection name from a token exchange invoke. + /// + internal OAuthFlow? Resolve(string? connectionName) + { + if (connectionName is not null && _flows.TryGetValue(connectionName, out OAuthFlow? flow)) + { + return flow; + } + + // If there's exactly one named flow, use it + if (_flows.Count == 1) + { + return _flows.Values.First(); + } + + return null; + } + + /// + /// Returns all registered flows. + /// + internal IEnumerable GetAllFlows() => _flows.Values; + + /// + /// Resolve when there's no connection name in the payload (e.g., verifyState). + /// Returns the single registered flow, or null if zero or multiple flows exist. + /// + internal OAuthFlow? ResolveSingle() + { + if (_flows.Count == 1) + { + return _flows.Values.First(); + } + + return null; + } + + /// + /// Like but when multiple flows are registered, + /// returns the first one and logs a warning instead of returning null. + /// Used by Context.IsSignedIn for backwards compatibility. + /// + internal OAuthFlow? ResolveSingleWithWarning() + { + if (_flows.Count == 1) + { + return _flows.Values.First(); + } + + if (_flows.Count > 1) + { + OAuthFlow first = _flows.Values.First(); + System.Diagnostics.Trace.TraceWarning( + $"IsSignedIn: multiple OAuthFlow connections registered. " + + $"Checking '{first.ConnectionName}' only. Use IsSignedInAsync(connectionName) for explicit control."); + return first; + } + + return null; + } + + /// + /// Returns all registered connection names, for use in error messages. + /// + internal IEnumerable GetRegisteredConnectionNames() => _flows.Keys; +} diff --git a/core/src/Microsoft.Teams.Apps/OAuth/OAuthOptions.cs b/core/src/Microsoft.Teams.Apps/OAuth/OAuthOptions.cs new file mode 100644 index 000000000..013ec48b6 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/OAuth/OAuthOptions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Apps.OAuth; + +/// +/// Options for the OAuth sign-in flow. +/// +public class OAuthOptions +{ + /// + /// The OAuth connection name to use. If null, uses the default registered connection. + /// When passed to , + /// this is required and identifies the connection. + /// + public string? ConnectionName { get; set; } + + /// + /// The text displayed on the OAuthCard. + /// + public string OAuthCardText { get; set; } = "Please Sign In"; + + /// + /// The text displayed on the sign-in button. + /// + public string SignInButtonText { get; set; } = "Sign In"; +} diff --git a/core/src/Microsoft.Teams.Apps/OAuth/SignInFailureValue.cs b/core/src/Microsoft.Teams.Apps/OAuth/SignInFailureValue.cs new file mode 100644 index 000000000..cf25c1cca --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/OAuth/SignInFailureValue.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.OAuth; + +/// +/// Value payload of the signin/failure invoke activity. +/// Sent by the Teams client when SSO token exchange fails client-side. +/// +/// +/// Known failure codes: +/// +/// installappfailedFailed to install the app in the user's personal scope. +/// authrequestfailedThe SSO auth request failed after app installation. +/// installedappnotfoundThe bot app is not installed for the user or group chat. +/// invokeerrorA generic error occurred during the SSO invoke flow. +/// resourcematchfailedThe token exchange resource URI does not match the Application ID URI in the Entra app's "Expose an API" section. +/// oauthcardnotvalidThe bot's OAuthCard could not be parsed. +/// tokenmissingAAD token acquisition failed. +/// userconsentrequiredThe user needs to consent (usually handled via OAuth card fallback). +/// interactionrequiredUser interaction is required (usually handled via OAuth card fallback). +/// +/// +public class SignInFailureValue +{ + /// + /// The failure code identifying the type of SSO failure. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// + /// A human-readable description of the failure. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/OAuth/SignInTokenExchangeValue.cs b/core/src/Microsoft.Teams.Apps/OAuth/SignInTokenExchangeValue.cs new file mode 100644 index 000000000..6f833145c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/OAuth/SignInTokenExchangeValue.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.OAuth; + +/// +/// Value payload of the signin/tokenExchange invoke activity. +/// +public class SignInTokenExchangeValue +{ + /// + /// Unique identifier for this token exchange request, used for deduplication. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The OAuth connection name this exchange targets. + /// + [JsonPropertyName("connectionName")] + public string? ConnectionName { get; set; } + + /// + /// The token provided by the Teams client for exchange. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/OAuth/SignInVerifyStateValue.cs b/core/src/Microsoft.Teams.Apps/OAuth/SignInVerifyStateValue.cs new file mode 100644 index 000000000..005be830c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/OAuth/SignInVerifyStateValue.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.OAuth; + +/// +/// Value payload of the signin/verifyState invoke activity. +/// +public class SignInVerifyStateValue +{ + /// + /// The magic code (state) from the fallback sign-in flow. + /// + [JsonPropertyName("state")] + public string? State { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/OAuth/TokenExchangeInvokeResponse.cs b/core/src/Microsoft.Teams.Apps/OAuth/TokenExchangeInvokeResponse.cs new file mode 100644 index 000000000..7118281f4 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/OAuth/TokenExchangeInvokeResponse.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.OAuth; + +/// +/// Response body returned in the invoke response for a failed signin/tokenExchange. +/// Sent with HTTP 412 (PreconditionFailed) to tell Teams to fall back to the sign-in card. +/// +public class TokenExchangeInvokeResponse +{ + /// + /// The token exchange request ID (echoed from the invoke value). + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The OAuth connection name (echoed from the invoke value). + /// + [JsonPropertyName("connectionName")] + public string? ConnectionName { get; set; } + + /// + /// Details about why the token exchange failed. + /// + [JsonPropertyName("failureDetail")] + public string? FailureDetail { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/README.md b/core/src/Microsoft.Teams.Apps/README.md new file mode 100644 index 000000000..c2fc16b5d --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/README.md @@ -0,0 +1,191 @@ + + + +# Microsoft.Teams.Apps + +A high-level framework for building Microsoft Teams bots in .NET. Built on top of `Microsoft.Teams.Core`, it provides Teams-specific activity types, a typed routing and handler system, OAuth authentication flows, Teams API clients, and streaming message support. + +## Key Features + +- **Typed Activity Routing** — Register handlers for specific activity types (`OnMessage`, `OnAdaptiveCardAction`, `OnQuery`, etc.) with type-safe contexts +- **Teams Activity Schema** — Rich type hierarchy (`MessageActivity`, `InvokeActivity`, `ConversationUpdateActivity`, etc.) with polymorphic deserialization +- **OAuth Flows** — Built-in SSO token exchange, sign-in cards, and token management via `OAuthFlow` +- **Teams API Clients** — Typed clients for conversations, members, teams, channels, meetings, and batch operations +- **Streaming Messages** — Progressive response updates via `TeamsStreamingWriter` +- **Fluent Configuration** — Chainable handler registration and `AppBuilder` for setup + +## Installation + +```shell +dotnet add package Microsoft.Teams.Apps +``` + +## Quick Start + +```csharp +using Microsoft.Teams.Apps; + +var builder = WebApplication.CreateBuilder(args); +builder.AddTeams(); + +var app = builder.Build(); +var teams = app.UseTeams(); // maps POST /api/messages + +teams.OnMessage(async (context, ct) => +{ + await context.Send($"You said: {context.Activity.Text}"); +}); + +teams.OnMembersAdded(async (context, ct) => +{ + foreach (var member in context.Activity.MembersAdded) + await context.Send($"Welcome, {member.Name}!"); +}); + +app.Run(); +``` + +## Handler Registration + +Handlers are registered as extension methods on `TeamsBotApplication` and can be chained: + +### Messages + +```csharp +// All messages +teams.OnMessage(async (context, ct) => { ... }); + +// Regex pattern match +teams.OnMessage(@"^help$", async (context, ct) => +{ + await context.Send("Here's how to use the bot..."); +}); +``` + +### Invoke Activities + +```csharp +// Adaptive card actions +teams.OnAdaptiveCardAction(async (context, ct) => +{ + var value = context.Activity.Value; + return new InvokeResponse(200); +}); + +// Message extension search +teams.OnQuery(async (context, ct) => +{ + var query = context.Activity.Value.Parameters["queryText"]; + return new InvokeResponse(200, response); +}); + +// Task modules +teams.OnFetchTask(async (context, ct) => { ... }); +teams.OnTaskSubmit(async (context, ct) => { ... }); + +// Link unfurling +teams.OnQueryLink(async (context, ct) => { ... }); +``` + +### Conversation Updates + +```csharp +teams.OnMembersAdded(async (context, ct) => { ... }); +teams.OnMembersRemoved(async (context, ct) => { ... }); +teams.OnChannelCreated(async (context, ct) => { ... }); +teams.OnTeamMemberAdded(async (context, ct) => { ... }); +``` + +### Other Events + +```csharp +teams.OnMessageReaction(async (context, ct) => { ... }); +teams.OnMessageUpdate(async (context, ct) => { ... }); +teams.OnMessageDelete(async (context, ct) => { ... }); +teams.OnInstallUpdate(async (context, ct) => { ... }); +teams.OnMeeting(async (context, ct) => { ... }); +``` + +## OAuth Authentication + +```csharp +// Configure OAuth during setup +var appBuilder = App.Builder().AddOAuth("graph"); +builder.AddTeams(appBuilder); + +// Register sign-in handlers +var flow = teams.GetOAuthFlow("graph"); + +flow.OnSignInComplete(async (context, tokenResponse, ct) => +{ + // Use token to call Microsoft Graph or other APIs +}); + +flow.OnSignInFailure(async (context, failure, ct) => +{ + await context.Send("Sign-in failed. Please try again."); +}); + +// Trigger sign-in from a message handler +teams.OnMessage(async (context, ct) => +{ + var flow = context.App.GetOAuthFlow("graph"); + await flow.SignInAsync(context, ct); +}); +``` + +## Teams API Clients + +Access Teams APIs through the typed `Context.Api` property: + +```csharp +teams.OnMessage(async (context, ct) => +{ + // Get conversation members + var members = await context.Api.Conversations.Members + .GetAsync(context.Activity.Conversation.Id); + + // Get team details + var team = await context.Api.Teams.GetAsync(teamId); + + // Send to a specific channel + await context.Api.Conversations.Activities + .SendAsync(channelId, activity); +}); +``` + +## Streaming Responses + +Send progressive message updates while the bot processes a request: + +```csharp +teams.OnMessage(async (context, ct) => +{ + var writer = TeamsStreamingWriter.CreateFromContext(context); + await writer.SendInformativeUpdateAsync("Thinking..."); + await writer.AppendResponseAsync("Here is "); + await writer.AppendResponseAsync("the answer."); + await writer.FinalizeResponseAsync(); +}); +``` + +## Routing Behavior + +- **Non-invoke activities**: All matching routes execute sequentially +- **Invoke activities**: Only the first matching route executes and must return a response +- Route names must be unique; mixing catch-all invoke handlers with specific invoke handlers is not allowed + +## Main Types + +| Type | Description | +|------|-------------| +| `TeamsBotApplication` | Main entry point — extends `BotApplication` with Teams-specific routing and features | +| `Context` | Per-turn context providing typed activity access, API clients, and helper methods | +| `TeamsActivity` | Base Teams activity with polymorphic deserialization into specific subtypes | +| `MessageActivity` | Text and attachment messages | +| `InvokeActivity` | Invoke operations (adaptive cards, task modules, message extensions) | +| `ConversationUpdateActivity` | Membership, channel, and team lifecycle events | +| `OAuthFlow` | OAuth sign-in, token exchange (SSO), and sign-out management | +| `ApiClient` | Facade for Teams conversation, member, team, meeting, and bot APIs | +| `TeamsStreamingWriter` | Progressive message streaming with rate limiting | +| `Router` | Internal activity dispatcher matching routes by type and selector | diff --git a/core/src/Microsoft.Teams.Apps/Routing/Route.cs b/core/src/Microsoft.Teams.Apps/Routing/Route.cs new file mode 100644 index 000000000..625c16133 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Routing/Route.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Routing; + +/// +/// Base class for routes, providing non-generic access to route functionality +/// +public abstract class RouteBase +{ + /// + /// Gets or sets the name of the route + /// + public abstract string Name { get; set; } + + /// + /// Determines if the route matches the given activity + /// + /// The activity to check. + /// True if the route matches the activity; otherwise, false. + public abstract bool Matches(TeamsActivity activity); + + /// + /// Invokes the route handler if the activity matches the expected type + /// + /// The activity context. + /// A cancellation token. + /// A task representing the asynchronous operation. + public abstract Task InvokeRoute(Context ctx, CancellationToken cancellationToken = default); + + /// + /// Invokes the route handler if the activity matches the expected type and returns a response + /// + /// The activity context. + /// A cancellation token. + /// A task representing the asynchronous operation. + public abstract Task InvokeRouteWithReturn(Context ctx, CancellationToken cancellationToken = default); +} + +/// +/// Represents a route for handling Teams activities +/// +public class Route : RouteBase where TActivity : TeamsActivity +{ + private string _name = string.Empty; + + /// + /// Gets or sets the name of the route + /// + public override string Name + { + get => _name; + set => _name = value; + } + + /// + /// Predicate function to determine if this route should handle the activity + /// + public Func Selector { get; set; } = _ => true; + + /// + /// Handler function to process the activity + /// + public Func, CancellationToken, Task>? Handler { get; set; } + + /// + /// Handler function to process the activity and return a response + /// + public Func, CancellationToken, Task>? HandlerWithReturn { get; set; } + + /// + /// Determines if the route matches the given activity + /// + /// The activity to check. + /// True if the route matches the activity; otherwise, false. + public override bool Matches(TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return activity is TActivity activity1 && Selector(activity1); + } + + /// + /// Invokes the route handler if the activity matches the expected type + /// + /// The activity context. + /// A cancellation token. + /// A task representing the asynchronous operation. + public override async Task InvokeRoute(Context ctx, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ctx); + Context typedContext = new(ctx.TeamsBotApplication, (TActivity)ctx.Activity); + await Handler!(typedContext, cancellationToken).ConfigureAwait(false); + } + + /// + /// Invokes the route handler if the activity matches the expected type and returns a response + /// + /// The activity context. + /// A cancellation token. + /// A task representing the asynchronous operation. + public override async Task InvokeRouteWithReturn(Context ctx, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ctx); + Context typedContext = new(ctx.TeamsBotApplication, (TActivity)ctx.Activity); + return await HandlerWithReturn!(typedContext, cancellationToken).ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Apps/Routing/Router.cs b/core/src/Microsoft.Teams.Apps/Routing/Router.cs new file mode 100644 index 000000000..1d43dfc71 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Routing/Router.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Routing; + +/// +/// Router for dispatching Teams activities to registered routes +/// +internal sealed class Router +{ + private readonly List _routes = []; + private readonly ILogger _logger; + + internal Router(ILogger logger) + { + _logger = logger; + } + + /// + /// Routes registered in the router. + /// + public IReadOnlyList GetRoutes() => _routes.AsReadOnly(); + + /// + /// Registers a route. Routes are checked and invoked in registration order. + /// For non-invoke activities all matching routes run sequentially. + /// For invoke activities — routes must be non-overlapping. + /// + /// + /// Thrown if a route with the same name is already registered, or if an invoke catch-all + /// is mixed with specific invoke handlers. + /// + public Router Register(Route route) where TActivity : TeamsActivity + { + if (_routes.Any(r => r.Name == route.Name)) + { + throw new InvalidOperationException($"A route with name '{route.Name}' is already registered."); + } + + string invokePrefix = TeamsActivityType.Invoke + "/"; + + if (route.Name == TeamsActivityType.Invoke && _routes.Any(r => r.Name.StartsWith(invokePrefix, StringComparison.Ordinal))) + { + throw new InvalidOperationException("Cannot register a catch-all invoke handler when specific invoke handlers are already registered. Use specific handlers or handle all invoke types inside OnInvoke."); + } + + if (route.Name.StartsWith(invokePrefix, StringComparison.Ordinal) && _routes.Any(r => r.Name == TeamsActivityType.Invoke)) + { + throw new InvalidOperationException($"Cannot register '{route.Name}' when a catch-all invoke handler is already registered. Remove OnInvoke or use specific handlers exclusively."); + } + _routes.Add(route); + _logger.LogDebug("Registered route '{Name}' for activity type '{ActivityType}'.", route.Name, typeof(TActivity).Name); + return this; + } + + /// + /// Dispatches the activity to all matching routes in registration order. + /// + public async Task DispatchAsync(Context ctx, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ctx); + + _logger.LogDebug("Routing activity of type '{Type}' against {RouteCount} registered routes.", ctx.Activity.Type, _routes.Count); + + List matchingRoutes = []; + foreach (RouteBase route in _routes) + { + bool matched = route.Matches(ctx.Activity); + _logger.LogTrace("Route '{Name}' selector returned {Result} for activity of type '{Type}'.", route.Name, matched, ctx.Activity.Type); + if (matched) + { + matchingRoutes.Add(route); + } + } + + if (matchingRoutes.Count == 0 && _routes.Count > 0) + { + _logger.LogWarning( + "No routes matched activity of type '{Type}'.", + ctx.Activity.Type + ); + return; + } + + _logger.LogDebug("Matched {MatchCount} route(s) for activity of type '{Type}'.", matchingRoutes.Count, ctx.Activity.Type); + + foreach (RouteBase route in matchingRoutes) + { + _logger.LogInformation("Dispatching '{Type}' activity to route '{Name}'.", ctx.Activity.Type, route.Name); + _logger.LogTrace("Dispatching activity to route '{Name}': {Activity}", route.Name, ctx.Activity.ToJson()); + await route.InvokeRoute(ctx, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Completed route '{Name}' for '{Type}' activity.", route.Name, ctx.Activity.Type); + } + } + + /// + /// Dispatches the specified activity context to the first matching route and returns the result of the invocation. + /// + /// The activity context to dispatch. Cannot be null. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains a response object with the outcome + /// of the invocation. + public async Task DispatchWithReturnAsync(Context ctx, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ctx); + + string? name = ctx.Activity is InvokeActivity inv ? inv.Name : null; + + _logger.LogDebug("Routing invoke activity with name '{Name}' against {RouteCount} registered routes.", name, _routes.Count); + + List matchingRoutes = []; + foreach (RouteBase route in _routes) + { + bool matched = route.Matches(ctx.Activity); + _logger.LogTrace("Route '{RouteName}' selector returned {Result} for invoke '{Name}'.", route.Name, matched, name); + if (matched) + { + matchingRoutes.Add(route); + } + } + + if (matchingRoutes.Count == 0 && _routes.Count > 0) + { + _logger.LogWarning("No routes matched invoke activity with name '{Name}'; returning 501.", name); + return new InvokeResponse(501); + } + + _logger.LogInformation("Dispatching invoke activity with name '{Name}' to route '{Route}'.", name, matchingRoutes[0].Name); + _logger.LogTrace("Dispatching invoke activity to route '{Route}': {Activity}", matchingRoutes[0].Name, ctx.Activity.ToJson()); + + InvokeResponse response = await matchingRoutes[0].InvokeRouteWithReturn(ctx, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("Completed invoke route '{Route}' for '{Name}' with status {Status}.", matchingRoutes[0].Name, name, response.Status); + + return response; + } + +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/ActivityQuotedReplyExtensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/ActivityQuotedReplyExtensions.cs new file mode 100644 index 000000000..84dcab969 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/ActivityQuotedReplyExtensions.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Security; + +namespace Microsoft.Teams.Apps.Schema.Entities; + +/// +/// Extension methods for Activity to handle quoted replies. +/// +[Experimental("ExperimentalTeamsQuotedReplies")] +public static class ActivityQuotedReplyExtensions +{ + /// + /// Builds the inline placeholder element that pairs with a . + /// XML-escapes so values containing ", <, & etc. can't break out of the attribute. + /// + internal static string QuotedPlaceholder(string messageId) + => $""; + + /// + /// Gets all quoted reply entities from the activity's entity collection. + /// + /// The activity to extract quoted replies from. Cannot be null. + /// An enumerable of QuotedReplyEntity instances found in the activity's entities. + public static IEnumerable GetQuotedMessages(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + if (activity.Entities == null) + { + return []; + } + return activity.Entities.OfType(); + } + + /// + /// Add a quoted message reference and append a placeholder to the message text. + /// Teams renders the quoted message as a preview bubble above the response text. + /// If text is provided, it is appended to the quoted message placeholder. + /// + /// The message activity to add the quote to. Cannot be null. + /// The ID of the message to quote. Cannot be null or whitespace. + /// Optional text, appended to the quoted message placeholder. + /// The created QuotedReplyEntity that was added to the activity. + public static QuotedReplyEntity AddQuote(this MessageActivity activity, string messageId, string? text = null) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(messageId); + + QuotedReplyEntity entity = new() { QuotedReply = new QuotedReplyData { MessageId = messageId } }; + activity.Entities ??= []; + activity.Entities.Add(entity); + + activity.Text = (activity.Text ?? "") + QuotedPlaceholder(messageId); + if (text != null) + { + activity.Text += $" {text}"; + } + + return entity; + } + + /// + /// Prepend a QuotedReply entity and placeholder before existing text. + /// Used by and + /// for quote-above-response. + /// + /// The message activity to prepend the quoted reply to. + /// The ID of the message to quote. + public static void PrependQuote(this MessageActivity activity, string messageId) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(messageId); + + activity.Entities ??= []; + activity.Entities.Insert(0, new QuotedReplyEntity { QuotedReply = new QuotedReplyData { MessageId = messageId } }); + var placeholder = QuotedPlaceholder(messageId); + var text = activity.Text?.Trim() ?? ""; + activity.Text = string.IsNullOrEmpty(text) ? placeholder : $"{placeholder} {text}"; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.cs new file mode 100644 index 000000000..731dd1d46 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.cs @@ -0,0 +1,437 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Schema.Entities; + +/// +/// Extension methods for Activity to handle citations and AI-generated content. +/// +public static class ActivityCitationExtensions +{ + /// + /// Adds a citation to the activity. Creates or updates the root message entity + /// with the specified citation claim. + /// + /// The activity to add the citation to. Cannot be null. + /// The position of the citation in the message text. + /// The citation appearance information. + /// The created CitationEntity that was added to the activity. + public static CitationEntity AddCitation(this TeamsActivity activity, int position, CitationAppearance appearance) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(appearance); + + activity.Entities ??= []; + OMessageEntity messageEntity = GetOrCreateRootMessageEntity(activity); + CitationEntity citationEntity = new(messageEntity); + citationEntity.Citation ??= []; + citationEntity.Citation.Add(new CitationClaim() + { + Position = position, + Appearance = appearance.ToDocument() + }); + + activity.Entities.Remove(messageEntity); + activity.Entities.Add(citationEntity); + return citationEntity; + } + + /// + /// Adds the AI-generated content label to the activity's root message entity. + /// This method is idempotent — calling it multiple times has the same effect as calling it once. + /// + /// The activity to mark as AI-generated. Cannot be null. + /// The OMessageEntity with the AI-generated label applied. + public static OMessageEntity AddAIGenerated(this TeamsActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + OMessageEntity messageEntity = GetOrCreateRootMessageEntity(activity); + messageEntity.AdditionalType ??= []; + + if (!messageEntity.AdditionalType.Contains("AIGeneratedContent")) + { + messageEntity.AdditionalType.Add("AIGeneratedContent"); + } + + return messageEntity; + } + + /// + /// Enables the feedback loop (thumbs up/down) on the activity's channel data. + /// + /// The activity to enable feedback on. Cannot be null. + /// Whether to enable feedback. Defaults to true. + /// The activity for chaining. + public static TeamsActivity AddFeedback(this TeamsActivity activity, bool value = true) + { + ArgumentNullException.ThrowIfNull(activity); + + activity.ChannelData ??= new TeamsChannelData(); + activity.ChannelData.FeedbackLoopEnabled = value; + return activity; + } + + /// + /// Adds a content sensitivity label to the activity. + /// + /// The activity to label. Cannot be null. + /// The sensitivity label name. + /// Optional description of the sensitivity. + /// Optional defined term pattern. + /// The activity for chaining. + public static TeamsActivity AddSensitivityLabel(this TeamsActivity activity, string name, string? description = null, DefinedTerm? pattern = null) + { + ArgumentNullException.ThrowIfNull(activity); + activity.AddEntity(new SensitiveUsageEntity() + { + Name = name, + Description = description, + Pattern = pattern + }); + return activity; + } + + // Gets or creates the single root-level OMessageEntity on the activity. + private static OMessageEntity GetOrCreateRootMessageEntity(TeamsActivity activity) + { + activity.Entities ??= []; + + OMessageEntity? messageEntity = activity.Entities.FirstOrDefault( + e => e.Type == "https://schema.org/Message" && e.OType == "Message" + ) as OMessageEntity; + + if (messageEntity is null) + { + messageEntity = new OMessageEntity(); + activity.Entities.Add(messageEntity); + } + + return messageEntity; + } +} + +/// +/// Citation entity representing a message with citation claims. +/// +public class CitationEntity : OMessageEntity +{ + /// + /// Creates a new instance of . + /// + public CitationEntity() : base() + { + } + + /// + /// Creates a new instance of by copying data from an existing message entity. + /// + /// The message entity to copy from. Cannot be null. + public CitationEntity(OMessageEntity entity) : base() + { + ArgumentNullException.ThrowIfNull(entity); + OType = entity.OType; + OContext = entity.OContext; + Type = entity.Type; + Properties = new Core.Schema.ExtendedPropertiesDictionary(entity.Properties); + AdditionalType = entity.AdditionalType != null + ? new List(entity.AdditionalType) + : null; + if (entity is CitationEntity citationEntity) + { + Citation = citationEntity.Citation != null + ? citationEntity.Citation.Select(c => new CitationClaim(c)).ToList() + : null; + } + } + + /// + /// Gets or sets the list of citation claims. + /// + [JsonPropertyName("citation")] + public IList? Citation + { + get => base.Properties.Get>("citation"); + set => base.Properties["citation"] = value; + } +} + +/// +/// Represents a citation claim with a position and appearance document. +/// +public class CitationClaim +{ + /// Initializes a new instance of . + public CitationClaim() { } + + /// Creates a deep copy of . + [SetsRequiredMembers] + public CitationClaim(CitationClaim other) + { + ArgumentNullException.ThrowIfNull(other); + Type = other.Type; + Position = other.Position; + Appearance = new CitationAppearanceDocument(other.Appearance); + } + + /// + /// Gets or sets the schema.org type. Always "Claim". + /// + [JsonPropertyName("@type")] + public string Type { get; set; } = "Claim"; + + /// + /// Gets or sets the position of the citation in the message text. + /// + [JsonPropertyName("position")] + public required int Position { get; set; } + + /// + /// Gets or sets the appearance document describing the cited source. + /// + [JsonPropertyName("appearance")] + public required CitationAppearanceDocument Appearance { get; set; } +} + +/// +/// Represents the appearance of a cited document. +/// +public class CitationAppearanceDocument +{ + /// Initializes a new instance of . + public CitationAppearanceDocument() { } + + /// Creates a deep copy of . + [SetsRequiredMembers] + public CitationAppearanceDocument(CitationAppearanceDocument other) + { + ArgumentNullException.ThrowIfNull(other); + Type = other.Type; + Name = other.Name; + Text = other.Text; + Url = other.Url; + Abstract = other.Abstract; + EncodingFormat = other.EncodingFormat; + Image = other.Image is null ? null : new CitationImageObject { Type = other.Image.Type, Name = other.Image.Name }; + Keywords = other.Keywords != null ? new List(other.Keywords) : null; + UsageInfo = other.UsageInfo; + } + + /// + /// Gets or sets the schema.org type. Always "DigitalDocument". + /// + [JsonPropertyName("@type")] + public string Type { get; set; } = "DigitalDocument"; + + /// + /// Gets or sets the name of the document (max length 80). + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets a stringified adaptive card with additional information about the citation. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Gets or sets the URL of the document. + /// + [JsonPropertyName("url")] + public Uri? Url { get; set; } + + /// + /// Gets or sets the extract of the referenced content (max length 160). + /// + [JsonPropertyName("abstract")] + public required string Abstract { get; set; } + + /// + /// Gets or sets the encoding format of the text. See for known values. + /// + [JsonPropertyName("encodingFormat")] + public string? EncodingFormat { get; set; } + + /// + /// Gets or sets the citation icon information. + /// + [JsonPropertyName("image")] + public CitationImageObject? Image { get; set; } + + /// + /// Gets or sets the keywords (max length 3, max keyword length 28). + /// + [JsonPropertyName("keywords")] + public IList? Keywords { get; set; } + + /// + /// Gets or sets the sensitivity usage information for the citation. + /// + [JsonPropertyName("usageInfo")] + public SensitiveUsageEntity? UsageInfo { get; set; } +} + +/// +/// Represents an image object used for citation icons. +/// +public class CitationImageObject +{ + /// + /// Gets or sets the schema.org type. Always "ImageObject". + /// + [JsonPropertyName("@type")] + public string Type { get; set; } = "ImageObject"; + + /// + /// Gets or sets the icon name. See for known values. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } +} + +/// +/// Known citation icon names. +/// +public static class CitationIcon +{ + /// Microsoft Word icon. + public const string MicrosoftWord = "Microsoft Word"; + + /// Microsoft Excel icon. + public const string MicrosoftExcel = "Microsoft Excel"; + + /// Microsoft PowerPoint icon. + public const string MicrosoftPowerPoint = "Microsoft PowerPoint"; + + /// Microsoft OneNote icon. + public const string MicrosoftOneNote = "Microsoft OneNote"; + + /// Microsoft SharePoint icon. + public const string MicrosoftSharePoint = "Microsoft SharePoint"; + + /// Microsoft Visio icon. + public const string MicrosoftVisio = "Microsoft Visio"; + + /// Microsoft Loop icon. + public const string MicrosoftLoop = "Microsoft Loop"; + + /// Microsoft Whiteboard icon. + public const string MicrosoftWhiteboard = "Microsoft Whiteboard"; + + /// Adobe Illustrator icon. + public const string AdobeIllustrator = "Adobe Illustrator"; + + /// Adobe Photoshop icon. + public const string AdobePhotoshop = "Adobe Photoshop"; + + /// Adobe InDesign icon. + public const string AdobeInDesign = "Adobe InDesign"; + + /// Adobe Flash icon. + public const string AdobeFlash = "Adobe Flash"; + + /// Sketch icon. + public const string Sketch = "Sketch"; + + /// Source code icon. + public const string SourceCode = "Source Code"; + + /// Image icon. + public const string Image = "Image"; + + /// GIF icon. + public const string Gif = "GIF"; + + /// Video icon. + public const string Video = "Video"; + + /// Sound icon. + public const string Sound = "Sound"; + + /// ZIP icon. + public const string Zip = "ZIP"; + + /// Text icon. + public const string Text = "Text"; + + /// PDF icon. + public const string Pdf = "PDF"; +} + +/// +/// Known encoding format MIME types for citation documents. +/// +public static class EncodingFormats +{ + /// Adaptive card encoding format. + public const string AdaptiveCard = "application/vnd.microsoft.card.adaptive"; +} + +/// +/// Helper class for building citation appearance documents. +/// +public class CitationAppearance +{ + /// + /// Gets or sets the name of the document (max length 80). + /// + public required string Name { get; set; } + + /// + /// Gets or sets a stringified adaptive card with additional information. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the URL of the document. + /// + public Uri? Url { get; set; } + + /// + /// Gets or sets the extract of the referenced content (max length 160). + /// + public required string Abstract { get; set; } + + /// + /// Gets or sets the encoding format of the text. See for known values. + /// + public string? EncodingFormat { get; set; } + + /// + /// Gets or sets the citation icon name. See for known values. + /// + public string? Icon { get; set; } + + /// + /// Gets or sets the keywords (max length 3, max keyword length 28). + /// + public IList? Keywords { get; set; } + + /// + /// Gets or sets the sensitivity usage information. + /// + public SensitiveUsageEntity? UsageInfo { get; set; } + + /// + /// Converts this appearance to a . + /// + /// The appearance document. + public CitationAppearanceDocument ToDocument() + { + return new() + { + Name = Name, + Text = Text, + Url = Url, + Abstract = Abstract, + EncodingFormat = EncodingFormat, + Image = Icon is null ? null : new CitationImageObject() { Name = Icon }, + Keywords = Keywords, + UsageInfo = UsageInfo + }; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/ClientInfoEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/ClientInfoEntity.cs new file mode 100644 index 000000000..7e0a0eb0a --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/ClientInfoEntity.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Schema.Entities; + + +/// +/// Extension methods for Activity to handle client info. +/// +public static class ActivityClientInfoExtensions +{ + /// + /// Adds client information to the activity's entity collection. + /// + /// The activity to add client information to. Cannot be null. + /// The platform identifier (e.g., "web", "desktop", "mobile"). + /// The country code (e.g., "US", "GB"). + /// The time zone identifier (e.g., "America/New_York"). + /// The locale identifier (e.g., "en-US", "fr-FR"). + /// The created ClientInfoEntity that was added 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); + return clientInfo; + } + + /// + /// Retrieves the client information entity from the activity's entity collection. + /// + /// The activity to extract client information from. Cannot be null. + /// The ClientInfoEntity if found in the activity's entities; otherwise, null. + 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") + { + } + + + /// + /// Initializes a new instance of the class with specified client information. + /// + /// The platform identifier (e.g., "web", "desktop", "mobile"). + /// The country code (e.g., "US", "GB"). + /// The time zone identifier (e.g., "America/New_York"). + /// The locale identifier (e.g., "en-US", "fr-FR"). + public ClientInfoEntity(string platform, string country, string timezone, string locale) : base("clientInfo") + { + Locale = locale; + Country = country; + Platform = platform; + Timezone = timezone; + } + + /// + /// Gets or sets the locale information. + /// + [JsonPropertyName("locale")] + public string? Locale + { + get => base.Properties.TryGetValue("locale", out object? value) ? value?.ToString() : null; + set => base.Properties["locale"] = value; + } + + /// + /// Gets or sets the country information. + /// + [JsonPropertyName("country")] + public string? Country + { + get => base.Properties.TryGetValue("country", out object? value) ? value?.ToString() : null; + set => base.Properties["country"] = value; + } + + /// + /// Gets or sets the platform information. + /// + [JsonPropertyName("platform")] + public string? Platform + { + get => base.Properties.TryGetValue("platform", out object? value) ? value?.ToString() : null; + set => base.Properties["platform"] = value; + } + + /// + /// Gets or sets the timezone information. + /// + [JsonPropertyName("timezone")] + public string? Timezone + { + get => base.Properties.TryGetValue("timezone", out object? value) ? value?.ToString() : null; + set => base.Properties["timezone"] = value; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/Entity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/Entity.cs new file mode 100644 index 000000000..5176baa86 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/Entity.cs @@ -0,0 +1,167 @@ +// 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.Core.Schema; + +namespace Microsoft.Teams.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 null; + } + 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: Should be able to support unknown types (PA uses BotMessageMetadata). + // 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" => DeserializeMessageEntity(item, options), + "ProductInfo" => item.Deserialize(options), + "streaminfo" => item.Deserialize(options), + "quotedReply" => item.Deserialize(options), + _ => item.Deserialize(options) + }; + if (entity != null) + entities.Add(entity); + } + } + return entities; + } + + /// + /// Deserializes a message entity by checking the @type property to determine the specific type. + /// + /// The JSON node to deserialize. + /// The JSON serializer options. + /// The deserialized entity, or null if deserialization fails. + private static OMessageEntity? DeserializeMessageEntity(JsonNode item, JsonSerializerOptions? options) + { + if (item is JsonObject jsonObject + && jsonObject.TryGetPropertyValue("@type", out JsonNode? oTypeNode) + && oTypeNode is JsonValue oTypeValue + && oTypeValue.GetValue() is string oType) + { + return oType switch + { + "Message" => item.Deserialize(options), + "CreativeWork" => item.Deserialize(options), + _ => item.Deserialize(options) + }; + } + + return item.Deserialize(options); + } +} + +/// +/// 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. + /// + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; + +} + +/// +/// 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.Apps/Schema/Entities/MentionEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/MentionEntity.cs new file mode 100644 index 000000000..f789adc23 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/MentionEntity.cs @@ -0,0 +1,115 @@ +// 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.Core.Schema; + +namespace Microsoft.Teams.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 (@ mention) of a user or bot to the activity. + /// + /// The activity to add the mention to. Cannot be null. + /// The conversation account being mentioned. Cannot be null. + /// Optional custom text for the mention. If null, uses the account name. + /// If true, prepends the mention text to the activity's existing text content. Defaults to true. + /// The created MentionEntity that was added 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 && activity is MessageActivity msg) + { + msg.Text = $"{mentionText} {msg.Text}"; + } + activity.Entities ??= []; + MentionEntity mentionEntity = new(account, $"{mentionText}"); + activity.Entities.Add(mentionEntity); + 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. + /// + /// The conversation account being mentioned. + /// The text representation of the mention, typically formatted as "<at>name</at>". + public MentionEntity(ConversationAccount mentioned, string? text) : base("mention") + { + Mentioned = mentioned; + Text = text; + } + + /// + /// Mentioned conversation account. + /// + [JsonPropertyName("mentioned")] + public ConversationAccount? Mentioned + { + get => base.Properties.Get("mentioned"); + set => base.Properties["mentioned"] = value; + } + + /// + /// Text of the mention. + /// + [JsonPropertyName("text")] + public string? Text + { + get => base.Properties.TryGetValue("text", out object? value) ? value?.ToString() : null; + set => base.Properties["text"] = value; + } + + /// + /// 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() + }; + return res; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/OMessageEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/OMessageEntity.cs new file mode 100644 index 000000000..5f584602c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/OMessageEntity.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Schema.Entities; + +/// +/// OMessage entity. +/// +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 => base.Properties.Get>("additionalType"); + set => base.Properties["additionalType"] = value; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/ProductInfoEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/ProductInfoEntity.cs new file mode 100644 index 000000000..d7a17958d --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/ProductInfoEntity.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Schema.Entities; + + + + +/// +/// Product info entity. +/// +public class ProductInfoEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public ProductInfoEntity() : base("ProductInfo") { } + + /// + /// Gets or sets the product id. + /// + [JsonPropertyName("id")] + public string? Id + { + get => base.Properties.TryGetValue("id", out object? value) ? value?.ToString() : null; + set => base.Properties["id"] = value; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/QuotedReplyEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/QuotedReplyEntity.cs new file mode 100644 index 000000000..6a285c066 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/QuotedReplyEntity.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Schema.Entities; + +/// +/// Represents a quoted reply entity in a Teams activity. +/// +[Experimental("ExperimentalTeamsQuotedReplies")] +public class QuotedReplyEntity : Entity +{ + /// + /// Creates a new instance of . + /// + public QuotedReplyEntity() : base("quotedReply") { } + + /// + /// Creates a new instance of with the specified message ID. + /// + /// The ID of the message being quoted. + public QuotedReplyEntity(string messageId) : base("quotedReply") + { + ArgumentException.ThrowIfNullOrWhiteSpace(messageId); + QuotedReply = new QuotedReplyData { MessageId = messageId }; + } + + /// + /// Gets or sets the quoted reply data. + /// + [JsonPropertyName("quotedReply")] + public QuotedReplyData? QuotedReply + { + get => base.Properties.Get("quotedReply"); + set => base.Properties["quotedReply"] = value; + } +} + +/// +/// Data for a quoted reply entity. +/// +[Experimental("ExperimentalTeamsQuotedReplies")] +public class QuotedReplyData +{ + /// + /// The ID of the quoted message. Required. + /// + [JsonPropertyName("messageId")] + public required string MessageId { get; set; } + + /// + /// The sender's bot-framework ID. Absent for deleted quotes. + /// + [JsonPropertyName("senderId")] + public string? SenderId { get; set; } + + /// + /// The sender's display name. Absent for deleted quotes and TFL senders. + /// + [JsonPropertyName("senderName")] + public string? SenderName { get; set; } + + /// + /// Preview of the quoted message text. Absent for deleted quotes and adaptive cards. + /// + [JsonPropertyName("preview")] + public string? Preview { get; set; } + + /// + /// Timestamp of the quoted message (IC3 epoch value, e.g. "1772050244572"). + /// Populated on inbound; ignored on outbound. Absent for deleted quotes. + /// + [JsonPropertyName("time")] + public string? Time { get; set; } + + /// + /// Whether the quoted message was deleted. Omitted when false. + /// + [JsonPropertyName("isReplyDeleted")] + public bool? IsReplyDeleted { get; set; } + + /// + /// Whether all quoted message references are valid and compliant. Only included when true. + /// + [JsonPropertyName("validatedMessageReference")] + public bool? ValidatedMessageReference { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/SensitiveUsageEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/SensitiveUsageEntity.cs new file mode 100644 index 000000000..44210dc50 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/SensitiveUsageEntity.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.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 => base.Properties.TryGetValue("name", out object? value) ? value?.ToString() ?? string.Empty : string.Empty; + set => base.Properties["name"] = value; + } + + /// + /// Gets or sets the description of the sensitive usage. + /// + [JsonPropertyName("description")] + public string? Description + { + get => base.Properties.TryGetValue("description", out object? value) ? value?.ToString() : null; + set => base.Properties["description"] = value; + } + + /// + /// Gets or sets the pattern associated with the sensitive usage. + /// + [JsonPropertyName("pattern")] + public DefinedTerm? Pattern + { + get => base.Properties.Get("pattern"); + set => base.Properties["pattern"] = value; + } +} + +/// +/// 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.Apps/Schema/Entities/StreamInfoEntity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/StreamInfoEntity.cs new file mode 100644 index 000000000..189877f18 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/StreamInfoEntity.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.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 => base.Properties.TryGetValue("streamId", out object? value) ? value?.ToString() : null; + set => base.Properties["streamId"] = value; + } + + /// + /// Gets or sets the stream type. See for possible values. + /// + [JsonPropertyName("streamType")] + public string? StreamType + { + get => base.Properties.TryGetValue("streamType", out object? value) ? value?.ToString() : null; + set => base.Properties["streamType"] = value; + } + + /// + /// Gets or sets the stream sequence. + /// + [JsonPropertyName("streamSequence")] + public int? StreamSequence + { + get => base.Properties.TryGetValue("streamSequence", out object? value) && value != null + ? (int.TryParse(value.ToString(), out int intVal) ? intVal : null) + : null; + set => base.Properties["streamSequence"] = value; + } +} + +/// +/// 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.Apps/Schema/MessageActivity.cs b/core/src/Microsoft.Teams.Apps/Schema/MessageActivity.cs new file mode 100644 index 000000000..4d06c8a8e --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/MessageActivity.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Represents a message activity. +/// +public class MessageActivity : TeamsActivity +{ + + /// + /// Convenience method to create a MessageActivity from a CoreActivity. + /// + /// The CoreActivity to convert. + /// A MessageActivity instance. + public static new MessageActivity FromActivity(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + return new MessageActivity(activity); + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public MessageActivity() : base(TeamsActivityType.Message) + { + } + + /// + /// Initializes a new instance of the class with the specified text. + /// + /// The text content of the message. + public MessageActivity(string text) : base(TeamsActivityType.Message) + { + Text = text; + } + + + /// + /// Initializes a new instance of the class with the specified text. + /// + /// The list of attachments for the message. + public MessageActivity(IList attachments) : base(TeamsActivityType.Message) + { + Attachments = attachments; + } + + /// + /// Internal constructor to create MessageActivity from CoreActivity. + /// + /// The CoreActivity to convert. + protected MessageActivity(CoreActivity activity) : base(activity) + { + Attachments = activity.Properties.Extract>("attachments"); + Text = activity.Properties.Extract("text"); + TextFormat = activity.Properties.Extract("textFormat"); + AttachmentLayout = activity.Properties.Extract("attachmentLayout"); + SuggestedActions = activity.Properties.Extract("suggestedActions"); + + /* + if (activity.Properties.TryGetValue("summary", out var summary)) + { + Summary = summary?.ToString(); + activity.Properties.Remove("summary"); + } + if (activity.Properties.TryGetValue("deliveryMode", out var deliveryMode)) + { + DeliveryMode = deliveryMode?.ToString(); + activity.Properties.Remove("deliveryMode"); + } + */ + } + + /// + /// Gets or sets the attachments for the message. + /// + [JsonPropertyName("attachments")] + public IList? Attachments { get; set; } + + /// + /// Gets or sets the text content of the message. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Gets the message text with the bot (recipient) @mention removed and trimmed. + /// In group chats, Teams prepends "<at>botname</at>" to the text when the bot is mentioned. + /// This property strips that mention so handlers can match on the user's intent alone. + /// + [JsonIgnore] + public string? TextWithoutMentions + { + get + { + string? text = Text; + if (text is null) return null; + + foreach (MentionEntity mention in this.GetMentions()) + { + if (mention.Mentioned?.Id == Recipient?.Id && mention.Text is not null) + { + text = text.Replace(mention.Text, string.Empty, StringComparison.OrdinalIgnoreCase); + } + } + return text.Trim(); + } + } + /// + /// Gets or sets the text format. See for common values. + /// + [JsonPropertyName("textFormat")] + public string? TextFormat { get; set; } + + /// + /// Gets or sets the attachment layout. + /// + [JsonPropertyName("attachmentLayout")] + public string? AttachmentLayout { get; set; } + + + + //TODO : Review properties + /* + /// + /// Gets or sets the summary of the message. + /// + [JsonPropertyName("summary")] + public string? Summary { get; set; } + + /// + /// Gets or sets the delivery mode. See for common values. + /// + [JsonPropertyName("deliveryMode")] + public string? DeliveryMode { get; set; } + */ + +} + +/// +/// String constants for text formats. +/// +public static class TextFormats +{ + /// + /// Plain text format. + /// + public const string Plain = "plain"; + + /// + /// Markdown text format. + /// + public const string Markdown = "markdown"; + + /// + /// XML text format. + /// + public const string Xml = "xml"; +} + +/* +/// +/// String constants for delivery modes. +/// +public static class DeliveryModes +{ + /// + /// Normal delivery mode. + /// + public const string Normal = "normal"; + + /// + /// Notification delivery mode. + /// + public const string Notification = "notification"; + + /// + /// Ephemeral delivery mode. + /// + public const string Ephemeral = "ephemeral"; + + /// + /// Expected replies delivery mode. + /// + public const string ExpectedReplies = "expectReplies"; +} +*/ diff --git a/core/src/Microsoft.Teams.Apps/Schema/MessageActivityExtensions.cs b/core/src/Microsoft.Teams.Apps/Schema/MessageActivityExtensions.cs new file mode 100644 index 000000000..1908d6759 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/MessageActivityExtensions.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Fluent extension methods for that delegate to internally. +/// These methods provide backward compatibility with the old library's message.WithText(...).WithSuggestedActions(...) pattern. +/// +public static class MessageActivityExtensions +{ + /// + /// Sets the text content of the message. + /// + /// The message activity. + /// The text to set. + /// The text format. Default is "plain". + /// The message activity for chaining. + public static MessageActivity WithText(this MessageActivity message, string text, string textFormat = TextFormats.Plain) + { + ArgumentNullException.ThrowIfNull(message); + message.Text = text; + message.TextFormat = textFormat; + return message; + } + + /// + /// Sets the suggested actions for the message. + /// + /// The message activity. + /// The suggested actions to set. + /// The message activity for chaining. + public static MessageActivity WithSuggestedActions(this MessageActivity message, SuggestedActions suggestedActions) + { + ArgumentNullException.ThrowIfNull(message); + message.SuggestedActions = suggestedActions; + return message; + } + + /// + /// Sets the text format for the message. + /// + /// The message activity. + /// The text format. See for common values. + /// The message activity for chaining. + public static MessageActivity WithTextFormat(this MessageActivity message, string textFormat) + { + ArgumentNullException.ThrowIfNull(message); + message.TextFormat = textFormat; + return message; + } + + /// + /// Sets the attachment layout for the message. + /// + /// The message activity. + /// The attachment layout (e.g., "list", "carousel"). + /// The message activity for chaining. + public static MessageActivity WithAttachmentLayout(this MessageActivity message, string attachmentLayout) + { + ArgumentNullException.ThrowIfNull(message); + message.AttachmentLayout = attachmentLayout; + return message; + } + + /// + /// Adds one or more attachments to the message. + /// + /// The message activity. + /// The attachments to add. + /// The message activity for chaining. + public static MessageActivity AddAttachment(this MessageActivity message, params TeamsAttachment[] attachments) + { + ArgumentNullException.ThrowIfNull(message); + ArgumentNullException.ThrowIfNull(attachments); + message.Attachments ??= []; + foreach (TeamsAttachment attachment in attachments) + { + message.Attachments.Add(attachment); + } + return message; + } + + /// + /// Marks the message as a final streaming message by adding a + /// with . + /// + /// The message activity. + /// The message activity for chaining. + public static MessageActivity AddStreamFinal(this MessageActivity message) + { + ArgumentNullException.ThrowIfNull(message); + message.Entities ??= []; + message.Entities.Add(new StreamInfoEntity { StreamType = StreamType.Final }); + return message; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/StreamingActivity.cs b/core/src/Microsoft.Teams.Apps/Schema/StreamingActivity.cs new file mode 100644 index 000000000..0e4eec813 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/StreamingActivity.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema.Entities; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Represents a streaming activity chunk. Has type "typing" to satisfy the Teams +/// streaming API, but carries text content that accumulates into the final response. +/// +public class StreamingActivity : TeamsActivity +{ + /// + /// Initializes a new instance of the class with the specified text. + /// + /// + [JsonConstructor] + public StreamingActivity(string text) : base(TeamsActivityType.Typing) + { + Text = text; + StreamInfo = new StreamInfoEntity(); + AddEntity(StreamInfo); + } + + /// + /// Gets or sets the text content of the streaming chunk. + /// + [JsonPropertyName("text")] + public string Text { get; set; } + + /// + /// Gets the stream info entity for this streaming activity. + /// + public StreamInfoEntity StreamInfo { get; } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/SuggestedAction.cs b/core/src/Microsoft.Teams.Apps/Schema/SuggestedAction.cs new file mode 100644 index 000000000..60d366a9a --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/SuggestedAction.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Represents a clickable action +/// +public class SuggestedAction +{ + /// + /// Default constructor for JSON deserialization. + /// + public SuggestedAction() + { + } + + /// + /// Initializes a new instance of the class with the specified type and title. + /// + /// The type of action. See for common values. + /// The text description displayed on the button. + public SuggestedAction(string type, string title) + { + Type = type; + Title = title; + } + + /// + /// Gets or sets the type of action implemented by this button. + /// See for common values. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Gets or sets the text description which appears on the button. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the image URL which will appear on the button, next to the text label. + /// + [JsonPropertyName("image")] + public string? Image { get; set; } + + /// + /// Gets or sets the text for this action. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Gets or sets the text to display in the chat feed if the button is clicked. + /// + [JsonPropertyName("displayText")] + public string? DisplayText { get; set; } + + /// + /// Gets or sets the supplementary parameter for the action. + /// The content of this property depends on the action type. + /// + [JsonPropertyName("value")] + public object? Value { get; set; } + + /// + /// Gets or sets the channel-specific data associated with this action. + /// + [JsonPropertyName("channelData")] + public object? ChannelData { get; set; } + + /// + /// Gets or sets the alternate image text to be used in place of the image. + /// + [JsonPropertyName("imageAltText")] + public string? ImageAltText { get; set; } +} + +/// +/// String constants for card action types. +/// +public static class ActionType +{ + /// + /// Opens the specified URL in the browser. + /// + public const string OpenUrl = "openUrl"; + + /// + /// Sends a message back to the bot as if the user typed it (visible to all conversation members). + /// + public const string IMBack = "imBack"; + + /// + /// Sends a message back to the bot privately (not visible to other conversation members). + /// + public const string PostBack = "postBack"; + + /// + /// Plays the specified audio content. + /// + public const string PlayAudio = "playAudio"; + + /// + /// Plays the specified video content. + /// + public const string PlayVideo = "playVideo"; + + /// + /// Displays the specified image. + /// + public const string ShowImage = "showImage"; + + /// + /// Downloads the specified file. + /// + public const string DownloadFile = "downloadFile"; + + /// + /// Initiates a sign-in flow. + /// + public const string SignIn = "signin"; + + /// + /// Initiates a phone call. + /// + public const string Call = "call"; +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/SuggestedActions.cs b/core/src/Microsoft.Teams.Apps/Schema/SuggestedActions.cs new file mode 100644 index 000000000..9ffc27786 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/SuggestedActions.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Represents suggested actions that can be shown to the user as quick reply buttons. +/// +public class SuggestedActions +{ + /// + /// Gets or sets the IDs of the recipients that the actions should be shown to. + /// These IDs are relative to the channelId and a subset of all recipients of the activity. + /// + [JsonPropertyName("to")] + public IList To { get; set; } = []; + + /// + /// Gets or sets the actions that can be shown to the user. + /// + [JsonPropertyName("actions")] + public IList Actions { get; set; } = []; + + /// + /// Adds recipients to the suggested actions. + /// + /// The recipient IDs to add. + /// This instance for chaining. + public SuggestedActions AddRecipients(params string[] recipients) + { + ArgumentNullException.ThrowIfNull(recipients); + foreach (string to in recipients) + { + To.Add(to); + } + + return this; + } + + /// + /// Adds a single action to the suggested actions. + /// + /// The action to add. + /// This instance for chaining. + public SuggestedActions AddAction(SuggestedAction action) + { + Actions.Add(action); + return this; + } + + /// + /// Adds multiple actions to the suggested actions. + /// + /// The actions to add. + /// This instance for chaining. + public SuggestedActions AddActions(params SuggestedAction[] actions) + { + ArgumentNullException.ThrowIfNull(actions); + foreach (SuggestedAction action in actions) + { + Actions.Add(action); + } + + return this; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/Team.cs b/core/src/Microsoft.Teams.Apps/Schema/Team.cs new file mode 100644 index 000000000..7fa7f37e8 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/Team.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.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.Apps/Schema/TeamsActivity.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivity.cs new file mode 100644 index 000000000..d9c9d2d57 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivity.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Teams Activity schema. +/// +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 TeamsActivityType.ActivityDeserializerMap.TryGetValue(activity.Type, out Func? factory) + ? factory(activity) + : new TeamsActivity(activity); // Fallback to base type + } + + /// + /// Overrides the ToJson method to serialize the TeamsActivity object to a JSON string. + /// Uses the appropriate JSON type info based on the actual runtime type. + /// + /// A JSON string representation of the activity using the type-specific serializer. + public override string ToJson() + => TeamsActivityType.ActivitySerializerMap.TryGetValue(GetType(), out Func? serializer) + ? serializer(this) + : ToJson(TeamsActivityJsonContext.Default.TeamsActivity); + + /// + /// Constructor with type parameter. + /// + /// + protected TeamsActivity(string type) : this() + { + Type = type; + } + + /// + /// Default constructor. + /// + [JsonConstructor] + public TeamsActivity() + { + Type = TeamsActivityType.Message; + } + + /// + /// Protected constructor to create TeamsActivity from CoreActivity. + /// Allows derived classes to call via base(activity). + /// + /// The CoreActivity to convert. + protected TeamsActivity(CoreActivity activity) : base(activity) + { + ArgumentNullException.ThrowIfNull(activity); + // Convert core extension properties to Teams-specific typed properties. + // CoreActivity stores these as untyped entries in its Properties dictionary + // (via [JsonExtensionData]), so we extract and promote them here. + base.From = TeamsConversationAccount.FromConversationAccount(activity.From) ?? new TeamsConversationAccount(); + base.Recipient = TeamsConversationAccount.FromConversationAccount(activity.Recipient) ?? new TeamsConversationAccount(); + base.Conversation = TeamsConversation.FromConversation(activity.Conversation) ?? new TeamsConversation(); + ChannelData = activity.Properties.Extract("channelData"); + Entities = activity.Properties.Extract("entities"); + } + + /// + /// Gets or sets the account information for the sender of the Teams conversation. + /// Delegates to the base CoreActivity.From slot, casting to TeamsConversationAccount. + /// + [JsonPropertyName("from")] + public new TeamsConversationAccount? From + { + get => base.From as TeamsConversationAccount ?? TeamsConversationAccount.FromConversationAccount(base.From); + set => base.From = value; + } + + /// + /// Gets or sets the account information for the recipient of the Teams conversation. + /// Delegates to the base CoreActivity.Recipient slot, casting to TeamsConversationAccount. + /// + [JsonPropertyName("recipient")] + public new TeamsConversationAccount? Recipient + { + get => base.Recipient as TeamsConversationAccount ?? TeamsConversationAccount.FromConversationAccount(base.Recipient); + set => base.Recipient = value; + } + + /// + /// Gets or sets the conversation information for the Teams conversation. + /// Delegates to the base CoreActivity.Conversation slot, casting to TeamsConversation. + /// + [JsonPropertyName("conversation")] + public new TeamsConversation? Conversation + { + get => base.Conversation as TeamsConversation ?? TeamsConversation.FromConversation(base.Conversation); + set => base.Conversation = value!; + } + + /// + /// Gets or sets the Teams-specific channel data associated with this activity. + /// + [JsonPropertyName("channelData")] + public TeamsChannelData? ChannelData { get; set; } + + /// + /// Gets or sets the entities specific to Teams. + /// + [JsonPropertyName("entities")] + public EntityList? Entities { get; set; } + + /// + /// UTC timestamp of when the activity was sent. + /// + [JsonPropertyName("timestamp")] + public string? Timestamp { get; set; } + + /// + /// Local timestamp of when the activity was sent, including timezone offset. + /// + [JsonPropertyName("localTimestamp")] + public string? LocalTimestamp { get; set; } + + /// + /// Locale of the activity set by the client (e.g., "en-US"). + /// + [JsonPropertyName("locale")] + public string? Locale { get; set; } + + /// + /// Local timezone of the client (e.g., "America/Los_Angeles"). + /// + [JsonPropertyName("localTimezone")] + public string? LocalTimezone { get; set; } + + /// + /// Gets or sets the suggested actions for the message. + /// + [JsonPropertyName("suggestedActions")] + public SuggestedActions? SuggestedActions { 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.Apps/Schema/TeamsActivityBuilder.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityBuilder.cs new file mode 100644 index 000000000..ca0586d17 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityBuilder.cs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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(new TeamsActivity()) + { + } + + /// + /// Initializes a new instance of the TeamsActivityBuilder class with an existing activity. + /// + /// The activity to build upon. + internal TeamsActivityBuilder(TeamsActivity activity) : base(activity) + { + } + + /// + /// Apply Conversation Reference from the specified activity. + /// + /// The source activity to copy conversation reference from. + /// The builder instance for chaining. + public TeamsActivityBuilder WithConversationReference(TeamsActivity 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); + WithConversation(activity.Conversation); + WithFrom(activity.Recipient); + + return this; + } + + /// + /// Sets the sender account information. + /// + /// The sender account. + /// The builder instance for chaining. + public new TeamsActivityBuilder WithFrom(ConversationAccount? from) + { + _activity.From = from is TeamsConversationAccount teamsAccount + ? teamsAccount + : TeamsConversationAccount.FromConversationAccount(from)!; + return this; + } + + /// + /// Sets the recipient account information. + /// + /// The recipient account. + /// The builder instance for chaining. + public new TeamsActivityBuilder WithRecipient(ConversationAccount? recipient) + { + _activity.Recipient = recipient is TeamsConversationAccount teamsAccount + ? teamsAccount + : TeamsConversationAccount.FromConversationAccount(recipient)!; + return this; + } + + /// + /// Sets the recipient account information and optionally marks this as a targeted message. + /// + /// The recipient account. + /// If true, marks this as a targeted message visible only to the specified recipient. + /// The builder instance for chaining. + public TeamsActivityBuilder WithRecipient(ConversationAccount? recipient, bool isTargeted) + { + if (recipient is not null) + { + recipient.IsTargeted = isTargeted ? true : null; + _activity.Recipient = recipient is TeamsConversationAccount teamsAccount + ? teamsAccount + : TeamsConversationAccount.FromConversationAccount(recipient)!; + } + return this; + } + + /// + /// Sets the conversation information. + /// + /// The conversation information. + /// The builder instance for chaining. + public new TeamsActivityBuilder WithConversation(Conversation? conversation) + { + ArgumentNullException.ThrowIfNull(conversation); + + _activity.Conversation = conversation is TeamsConversation teamsConv + ? teamsConv + : TeamsConversation.FromConversation(conversation); + + return this; + } + + /// + /// 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) + { + if (_activity is MessageActivity msg) + msg.Attachments = attachments; + else + _activity.Properties["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) + { + IList? attachments = attachment is null ? null : [attachment]; + if (_activity is MessageActivity msg) + msg.Attachments = attachments; + else + _activity.Properties["attachments"] = attachments; + 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) + { + if (_activity is MessageActivity msg) + { + msg.Attachments ??= []; + msg.Attachments.Add(attachment); + } + else + { + if (!_activity.Properties.TryGetValue("attachments", out object? existing) || existing is not List list) + { + list = []; + _activity.Properties["attachments"] = list; + } + list.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; + } + + /// + /// With Suggested Actions + /// + /// + /// + public TeamsActivityBuilder WithSuggestedActions(SuggestedActions suggestedActions) + { + ArgumentNullException.ThrowIfNull(_activity); + _activity.SuggestedActions = suggestedActions; + return this; + } + + /// + /// Adds a quoted reply entity and appends a placeholder to the activity text. + /// The activity type must be set to Message (via ) before calling this method. + /// + /// The ID of the message to quote. + /// Optional text, appended to the quoted message placeholder. + /// The builder instance for chaining. + /// Thrown when the activity type is not Message. + [Experimental("ExperimentalTeamsQuotedReplies")] + public TeamsActivityBuilder WithQuote(string messageId, string? text = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(messageId); + + if (_activity.Type != TeamsActivityType.Message) + { + throw new InvalidOperationException("WithQuote can only be used on message activities. Call WithType(TeamsActivityType.Message) first."); + } + + _activity.Entities ??= []; + _activity.Entities.Add(new QuotedReplyEntity + { + QuotedReply = new QuotedReplyData { MessageId = messageId } + }); + + string? currentText; + if (_activity is MessageActivity msgRead) + { + currentText = msgRead.Text; + } + else + { + currentText = _activity.Properties.TryGetValue("text", out object? value) ? value?.ToString() : null; + } + + var placeholder = ActivityQuotedReplyExtensions.QuotedPlaceholder(messageId); + var newText = (currentText ?? "") + placeholder; + if (text != null) + { + newText += $" {text}"; + } + + if (_activity is MessageActivity msg) + { + msg.Text = newText; + } + else + { + WithProperty("text", newText); + } + + 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}")); + + return this; + } + + /// + /// Builds and returns the configured TeamsActivity instance. + /// + /// The configured TeamsActivity. + public override TeamsActivity Build() + { + 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.Apps/Schema/TeamsActivityJsonContext.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityJsonContext.cs new file mode 100644 index 000000000..2040c4f0e --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityJsonContext.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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(MessageActivity))] +[JsonSerializable(typeof(StreamingActivity))] +[JsonSerializable(typeof(Entity))] +[JsonSerializable(typeof(EntityList))] +[JsonSerializable(typeof(MentionEntity))] +[JsonSerializable(typeof(ClientInfoEntity))] +[JsonSerializable(typeof(OMessageEntity))] +[JsonSerializable(typeof(SensitiveUsageEntity))] +[JsonSerializable(typeof(DefinedTerm))] +[JsonSerializable(typeof(ProductInfoEntity))] +[JsonSerializable(typeof(StreamInfoEntity))] +[JsonSerializable(typeof(CitationEntity))] +[JsonSerializable(typeof(QuotedReplyEntity))] +[JsonSerializable(typeof(QuotedReplyData))] +[JsonSerializable(typeof(CitationClaim))] +[JsonSerializable(typeof(CitationAppearanceDocument))] +[JsonSerializable(typeof(CitationImageObject))] +[JsonSerializable(typeof(CitationAppearance))] +[JsonSerializable(typeof(SuggestedActions))] +[JsonSerializable(typeof(SuggestedAction))] +[JsonSerializable(typeof(TeamsChannelData))] +[JsonSerializable(typeof(ConversationAccount))] +[JsonSerializable(typeof(TeamsConversationAccount))] +[JsonSerializable(typeof(TeamsConversation))] +[JsonSerializable(typeof(ExtendedPropertiesDictionary))] +[JsonSerializable(typeof(TeamsAttachment))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonObject))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonNode))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonArray))] +[JsonSerializable(typeof(System.Text.Json.Nodes.JsonValue))] +[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.Apps/Schema/TeamsActivityType.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityType.cs new file mode 100644 index 000000000..bc3002aec --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityType.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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 a message reaction activity. + /// + public const string MessageReaction = "messageReaction"; + /// + /// Represents a message update activity. + /// + public const string MessageUpdate = "messageUpdate"; + /// + /// Represents a message delete activity. + /// + public const string MessageDelete = "messageDelete"; + + /// + /// Represents a conversation update activity. + /// + public const string ConversationUpdate = "conversationUpdate"; + + /* + /// + /// Represents an end of conversation activity. + /// + public const string EndOfConversation = "endOfConversation"; + */ + + /// + /// Represents an installation update activity. + /// + public const string InstallationUpdate = "installationUpdate"; + + /// + /// Represents the string value "invoke" used to identify an invoke operation or action. + /// + public const string Invoke = "invoke"; + + /// + /// Represents an event activity. + /// + public const string Event = "event"; + + //TODO : review command activity + /* + /// + /// Represents a command activity. + /// + public const string Command = "command"; + + /// + /// Represents a command result activity. + /// + public const string CommandResult = "commandResult"; + */ + + /// + /// Registry of activity type factories for creating specialized activity instances. + /// + internal static readonly Dictionary> ActivityDeserializerMap = new() + { + [Message] = MessageActivity.FromActivity, + [MessageReaction] = MessageReactionActivity.FromActivity, + [MessageUpdate] = MessageUpdateActivity.FromActivity, + [MessageDelete] = MessageDeleteActivity.FromActivity, + [ConversationUpdate] = ConversationUpdateActivity.FromActivity, + //[TeamsActivityType.EndOfConversation] = EndOfConversationActivity.FromActivity, + [InstallationUpdate] = InstallUpdateActivity.FromActivity, + [Invoke] = InvokeActivity.FromActivity, + [Event] = EventActivity.FromActivity + }; + + /// + /// Registry of serializers keyed by concrete activity type, mirroring . + /// + internal static readonly Dictionary> ActivitySerializerMap = new() + { + [typeof(MessageActivity)] = a => a.ToJson(TeamsActivityJsonContext.Default.MessageActivity), + [typeof(StreamingActivity)] = a => a.ToJson(TeamsActivityJsonContext.Default.StreamingActivity), + }; +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsAttachment.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsAttachment.cs new file mode 100644 index 000000000..b81ff08da --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsAttachment.cs @@ -0,0 +1,177 @@ +// 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.Core.Schema; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Teams attachment content types. +/// +public static class AttachmentContentType +{ + /// + /// Adaptive Card content type. + /// + public const string AdaptiveCard = "application/vnd.microsoft.card.adaptive"; + + /// + /// Hero Card content type. + /// + public const string HeroCard = "application/vnd.microsoft.card.hero"; + + /// + /// Thumbnail Card content type. + /// + public const string ThumbnailCard = "application/vnd.microsoft.card.thumbnail"; + + /// + /// Office 365 Connector Card content type. + /// + public const string O365ConnectorCard = "application/vnd.microsoft.teams.card.o365connector"; + + /// + /// File consent card content type. + /// + public const string FileConsentCard = "application/vnd.microsoft.teams.card.file.consent"; + + /// + /// File info card content type. + /// + public const string FileInfoCard = "application/vnd.microsoft.teams.card.file.info"; + + /// + /// OAuth Card content type, used for initiating OAuth sign-in flows. + /// + public const string OAuthCard = "application/vnd.microsoft.card.oauth"; + + //TODO : verify these + /* + /// + /// Receipt Card content type. + /// + public const string ReceiptCard = "application/vnd.microsoft.card.receipt"; + + /// + /// Signin Card content type. + /// + public const string SigninCard = "application/vnd.microsoft.card.signin"; + + /// + /// Animation content type. + /// + public const string Animation = "application/vnd.microsoft.card.animation"; + + /// + /// Audio content type. + /// + public const string Audio = "application/vnd.microsoft.card.audio"; + + /// + /// Video content type. + /// + public const string Video = "application/vnd.microsoft.card.video"; + */ +} + +/// +/// Attachment layout types. +/// +public static class TeamsAttachmentLayout +{ + /// + /// List layout - displays attachments in a vertical list. + /// + public const string List = "list"; + + /// + /// Grid layout - displays attachments in a grid. + /// + public const string Grid = "grid"; + + /// + /// Carousel layout - displays attachments in a horizontal carousel. + /// + public const string Carousel = "carousel"; +} + +/// +/// 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 null; + } + List attachments = []; + foreach (JsonNode? item in jsonArray) + { + attachments.Add(item.Deserialize()!); + } + 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. + /// + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; + + /// + /// 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.Apps/Schema/TeamsAttachmentBuilder.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsAttachmentBuilder.cs new file mode 100644 index 000000000..8e24dca87 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsAttachmentBuilder.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.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() => new() + { + ContentType = _attachment.ContentType, + Content = _attachment.Content, + ContentUrl = _attachment.ContentUrl, + Name = _attachment.Name, + ThumbnailUrl = _attachment.ThumbnailUrl, + }; +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsChannel.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsChannel.cs new file mode 100644 index 000000000..24e8859ed --- /dev/null +++ b/core/src/Microsoft.Teams.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.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.Apps/Schema/TeamsChannelData.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs new file mode 100644 index 000000000..2fa6446e5 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.Schema; + +/// +/// Represents the source of a Teams activity. +/// +public class TeamsChannelDataSource +{ + /// + /// The name of the source. + /// + [JsonPropertyName("name")] public string? Name { get; set; } +} + +/// +/// Tenant information for Teams channel data. +/// +public class TeamsChannelDataTenant +{ + /// + /// Unique identifier of the tenant. + /// + [JsonPropertyName("id")] public string? Id { get; set; } +} + +/// +/// 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. + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; +} + +/// +/// 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) + { + //TODO : is channel id needed ? what is teamschannleid and teamsteamid ? + if (cd.Properties.TryGetValue("teamsChannelId", out object? channelIdObj) + && channelIdObj is JsonElement jeChannelId + && jeChannelId.ValueKind == JsonValueKind.String) + { + TeamsChannelId = jeChannelId.GetString(); + } + + if (cd.Properties.TryGetValue("teamsTeamId", out object? teamIdObj) + && teamIdObj is JsonElement jeTeamId + && jeTeamId.ValueKind == JsonValueKind.String) + { + TeamsTeamId = jeTeamId.GetString(); + } + + if (cd.Properties.TryGetValue("settings", out object? settingsObj) + && settingsObj is JsonElement settingsObjJE + && settingsObjJE.ValueKind == JsonValueKind.Object) + { + Settings = JsonSerializer.Deserialize(settingsObjJE.GetRawText()); + } + + 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()); + } + + if (cd.Properties.TryGetValue("eventType", out object? eventTypeObj) + && eventTypeObj is JsonElement jeEventType + && jeEventType.ValueKind == JsonValueKind.String) + { + EventType = jeEventType.GetString(); + } + + if (cd.Properties.TryGetValue("team", out object? teamObj) + && teamObj is JsonElement teamObjJE + && teamObjJE.ValueKind == JsonValueKind.Object) + { + Team = JsonSerializer.Deserialize(teamObjJE.GetRawText()); + } + + if (cd.Properties.TryGetValue("source", out object? sourceObj) + && sourceObj is JsonElement sourceObjJE + && sourceObjJE.ValueKind == JsonValueKind.Object) + { + Source = JsonSerializer.Deserialize(sourceObjJE.GetRawText()); + } + + if (cd.Properties.TryGetValue("feedbackLoopEnabled", out object? feedbackObj) + && feedbackObj is JsonElement jeFeedback + && jeFeedback.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + FeedbackLoopEnabled = jeFeedback.GetBoolean(); + } + } + } + + + /// + /// 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; } + + /// + /// Gets or sets the event type for conversation updates. See for known values. + /// + [JsonPropertyName("eventType")] public string? EventType { get; set; } + + /// + /// Source information for the activity. + /// + [JsonPropertyName("source")] public TeamsChannelDataSource? Source { get; set; } + + /// + /// Gets or sets whether the feedback loop (thumbs up/down) is enabled for the activity. + /// + [JsonPropertyName("feedbackLoopEnabled")] public bool? FeedbackLoopEnabled { get; set; } + +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversation.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversation.cs new file mode 100644 index 000000000..48acbbd3c --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversation.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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"; + + /// + /// Channel conversation + /// + public const string Channel = "channel"; +} + +/// +/// Teams Conversation schema. +/// +public class TeamsConversation : Conversation +{ + /// + /// Initializes a new instance of the TeamsConversation class. + /// + [JsonConstructor] + public TeamsConversation() + { + } + + /// + /// Creates a Teams Conversation from a Conversation + /// + /// + /// + public static TeamsConversation? FromConversation(Conversation? conversation) + { + if (conversation is null) + { + return null; + } + TeamsConversation result = new(); + result.Id = conversation.Id; + if (conversation.Properties == null) + { + return result; + } + if (conversation.Properties.TryGetValue("tenantId", out object? tenantObj)) + { + result.TenantId = tenantObj?.ToString(); + } + if (conversation.Properties.TryGetValue("conversationType", out object? convTypeObj)) + { + result.ConversationType = convTypeObj?.ToString(); + } + if (conversation.Properties.TryGetValue("isGroup", out object? isGroupObj)) + { + result.IsGroup = Convert.ToBoolean(isGroupObj?.ToString()); + } + return result; + } + + /// + /// Tenant Id. + /// + [JsonPropertyName("tenantId")] public string? TenantId { get; set; } + + /// + /// Conversation Type. See for known values. + /// + [JsonPropertyName("conversationType")] public string? ConversationType { get; set; } + + /// + /// Indicates whether the conversation is a group conversation. + /// + [JsonPropertyName("isGroup")] public bool? IsGroup { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs new file mode 100644 index 000000000..566e0d336 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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 +{ + /// + /// Initializes a new instance of the TeamsConversationAccount class. + /// + [JsonConstructor] + public TeamsConversationAccount() + { + } + + /// + /// Initializes a new instance of the TeamsConversationAccount class using the specified conversation account. + /// + /// The ConversationAccount instance containing the conversation's identifier, name, and properties. Cannot be null. + public static TeamsConversationAccount? FromConversationAccount(ConversationAccount? conversationAccount) + { + if (conversationAccount is null) + { + return null; + } + TeamsConversationAccount result = new(); + result.Id = conversationAccount.Id; + result.Name = conversationAccount.Name; + result.IsTargeted = conversationAccount.IsTargeted; + result.AgenticAppId = conversationAccount.AgenticAppId; + result.AgenticUserId = conversationAccount.AgenticUserId; + result.AgenticAppBlueprintId = conversationAccount.AgenticAppBlueprintId; + result.Properties = conversationAccount.Properties; + return result; + } + + /// + /// Gets or sets the Azure Active Directory (AAD) Object ID associated with the conversation account. + /// + [JsonIgnore] + public string? AadObjectId + { + get => GetStringProperty("aadObjectId"); + set => Properties["aadObjectId"] = value; + } + + /// + /// Gets or sets given name part of the user name. + /// + [JsonIgnore] + public string? GivenName + { + get => GetStringProperty("givenName"); + set => Properties["givenName"] = value; + } + + /// + /// Gets or sets surname part of the user name. + /// + [JsonIgnore] + public string? Surname + { + get => GetStringProperty("surname"); + set => Properties["surname"] = value; + } + + /// + /// Gets or sets email Id of the user. + /// + [JsonIgnore] + public string? Email + { + get => GetStringProperty("email"); + set => Properties["email"] = value; + } + + /// + /// Gets or sets unique user principal name. + /// + [JsonIgnore] + public string? UserPrincipalName + { + get => GetStringProperty("userPrincipalName"); + set => Properties["userPrincipalName"] = value; + } + + /// + /// Gets or sets the UserRole. + /// + [JsonIgnore] + public string? UserRole + { + get => GetStringProperty("userRole"); + set => Properties["userRole"] = value; + } + + /// + /// Gets or sets the TenantId. + /// + [JsonIgnore] + public string? TenantId + { + get => GetStringProperty("tenantId"); + set => Properties["tenantId"] = value; + } + + private string? GetStringProperty(string key) + { + if (Properties.TryGetValue(key, out object? val) && val is JsonElement je && je.ValueKind == JsonValueKind.String) + { + return je.GetString(); + } + return val?.ToString(); + } +} diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs new file mode 100644 index 000000000..de324edc1 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Core.Hosting; + +namespace Microsoft.Teams.Apps; + +/// +/// Extension methods for . +/// +public static class TeamsBotApplicationHostingExtensions +{ + /// + /// Registers Teams bot application services using the . + /// This is a convenience method that delegates to builder.Services.AddTeams(). + /// + /// The web application builder. + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The service collection for chaining. + public static IServiceCollection AddTeams(this WebApplicationBuilder builder, string sectionName = "AzureAd") + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Services.AddTeams(sectionName); + } + + /// + /// Registers Teams bot application services using the with an . + /// This supports the App.Builder().AddOAuth("graph") pattern from the old library. + /// + /// The web application builder. + /// The app builder containing configuration. + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The service collection for chaining. + public static IServiceCollection AddTeams(this WebApplicationBuilder builder, AppBuilder appBuilder, string sectionName = "AzureAd") + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(appBuilder); + return builder.Services.AddTeamsBotApplication(options => + { + foreach (TeamsBotApplicationOptions.OAuthFlowDescriptor flow in appBuilder.Options.OAuthFlows) + { + options.AddOAuthFlow(flow.ConnectionName); + } + }, sectionName); + } + + /// + /// Registers Teams bot application services with the specified service collection. + /// + /// This method provides a simplified way to configure Teams bot support by encapsulating the + /// necessary service registrations and configuration binding. + /// The service collection to which Teams bot application services will be added. Cannot be null. + /// The name of the configuration section containing Azure Active Directory settings. Defaults to "AzureAd" if not + /// specified. + /// The service collection with Teams bot application services registered. + public static IServiceCollection AddTeams(this IServiceCollection services, string sectionName = "AzureAd") + => AddTeamsBotApplication(services, sectionName); + + /// + /// Adds the default to the service collection. + /// + /// The service collection. + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The service collection for chaining. + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, string sectionName = "AzureAd") + { + return AddTeamsBotApplication(services, sectionName); + } + + /// + /// Adds the default TeamsBotApplication with configuration options. + /// + /// The service collection. + /// A delegate to configure . + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The service collection for chaining. + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, Action configure, string sectionName = "AzureAd") + { + return AddTeamsBotApplication(services, configure, sectionName); + } + + /// + /// Adds a custom to the service collection. + /// + /// The service collection. + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The service collection for chaining. + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, string sectionName = "AzureAd") where TApp : TeamsBotApplication + { + return AddTeamsBotApplication(services, configure: null, sectionName); + } + + /// + /// Adds a custom TeamsBotApplication with configuration options. + /// + /// The custom TeamsBotApplication type. + /// The service collection. + /// A delegate to configure . Can be null. + /// The configuration section name for AzureAd settings. Default is "AzureAd". + /// The service collection for chaining. + public static IServiceCollection AddTeamsBotApplication(this IServiceCollection services, Action? configure, string sectionName = "AzureAd") where TApp : TeamsBotApplication + { + BotConfig botConfig = BotConfig.Resolve(services, sectionName); + + services.AddBotClient(nameof(ApiClient), botConfig); + + // Register TeamsBotApplicationOptions + TeamsBotApplicationOptions teamsOptions = new(); + configure?.Invoke(teamsOptions); + services.AddSingleton(teamsOptions); + + services.AddBotApplication(botConfig); + return services; + } + + /// + /// Configures a custom on the endpoint route builder. + /// + /// The custom type. + /// The endpoint route builder. + /// The route path to listen on. Default is "api/messages". + /// The configured instance. + public static TApp UseTeamsBotApplication(this IEndpointRouteBuilder endpoints, + string routePath = "api/messages") + where TApp : TeamsBotApplication + => endpoints.UseBotApplication(routePath); + + /// + /// Configures the default on the endpoint route builder. + /// + /// The endpoint route builder. + /// The route path to listen on. Default is "api/messages". + /// The configured instance. + public static TeamsBotApplication UseTeamsBotApplication(this IEndpointRouteBuilder endpoints, + string routePath = "api/messages") + => endpoints.UseBotApplication(routePath); + + /// + /// Configures the default . Alias for . + /// + /// The endpoint route builder. + /// The route path to listen on. Default is "api/messages". + /// The configured instance. + public static TeamsBotApplication UseTeams(this IEndpointRouteBuilder endpoints, string routePath = "api/messages") + => endpoints.UseBotApplication(routePath); +} diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs new file mode 100644 index 000000000..79bcff879 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.OAuth; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; + +namespace Microsoft.Teams.Apps; + +/// +/// Teams-specific bot application. +/// +public class TeamsBotApplication : BotApplication +{ + private readonly Api.Clients.ApiClient _teamsApiClient; + private Uri? _lastServiceUrl; + + /// + /// Gets the logger instance for this application, used by . + /// + internal ILogger Logger { get; } + + /// + /// Gets the router for dispatching Teams activities to registered routes. + /// + internal Router Router { get; } + + /// + /// Gets the registry of OAuthFlow instances. Set by AddOAuthFlow. + /// + internal OAuthFlowRegistry? OAuthRegistry { get; set; } + + /// + /// Gets a registered by connection name. + /// Use this to attach callbacks (, ) + /// to flows that were configured via . + /// + /// The OAuth connection name. + /// The instance. + /// No flow is registered for the given connection name. + public OAuthFlow GetOAuthFlow(string connectionName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + + OAuthFlow? flow = OAuthRegistry?.Resolve(connectionName); + if (flow is null) + { + IEnumerable registered = OAuthRegistry?.GetRegisteredConnectionNames() ?? []; + throw new InvalidOperationException( + $"No OAuthFlow registered for connection '{connectionName}'. " + + $"Registered connections: [{string.Join(", ", registered)}]."); + } + + return flow; + } + + /// + /// Gets the client used to interact with the Teams API service. + /// + public ApiClient TeamsApiClient => _teamsApiClient; + /// + /// Gets the hierarchical API facade for Teams operations. + /// + /// + /// This property provides a structured API for accessing Teams operations through a hierarchy: + /// + /// Api.Conversations.Activities - Activity operations (send, update, delete) + /// Api.Conversations.Members - Member operations (get, delete) + /// Api.Users.Token - User token operations (OAuth SSO, sign-in resources) + /// Api.Teams - Team operations (get details, channels) + /// Api.Meetings - Meeting operations (get info, participant, notifications) + /// Api.Batch - Batch messaging operations + /// + /// + public ApiClient Api { get; } + + /// The conversation client for sending and managing activities. + /// The user token client for OAuth operations. + /// The Teams API client for Teams-specific operations. + /// The HTTP context accessor for reading invoke responses. + /// The logger instance. + /// Options containing the application (client) ID, used for logging and diagnostics. Defaults to an empty instance if not provided. + /// Teams-specific options including OAuth flow configuration. Defaults to an empty instance if not provided. + public TeamsBotApplication( + ConversationClient conversationClient, + UserTokenClient userTokenClient, + ApiClient teamsApiClient, + IHttpContextAccessor httpContextAccessor, + ILogger logger, + BotApplicationOptions? options = null, + TeamsBotApplicationOptions? teamsOptions = null) + : base(conversationClient, userTokenClient, logger, options) + { + _teamsApiClient = teamsApiClient; + Api = teamsApiClient; + Logger = logger; + Router = new Router(logger); + + // Auto-register OAuth flows from DI options + if (teamsOptions is not null) + { + foreach (TeamsBotApplicationOptions.OAuthFlowDescriptor descriptor in teamsOptions.OAuthFlows) + { + this.AddOAuthFlow(descriptor.Options); + } + } + OnActivity = async (activity, cancellationToken) => + { + logger.LogDebug("OnActivity invoked for activity: Id={Id}", activity.Id); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(activity); + + // Cache the service URL for proactive messaging + if (teamsActivity.ServiceUrl is not null) + { + _lastServiceUrl = teamsActivity.ServiceUrl; + } + + Context defaultContext = new(this, teamsActivity); + + if (teamsActivity.Type != TeamsActivityType.Invoke) + { + await Router.DispatchAsync(defaultContext, cancellationToken).ConfigureAwait(false); + } + else // invokes + { + InvokeResponse invokeResponse = await Router.DispatchWithReturnAsync(defaultContext, cancellationToken).ConfigureAwait(false); + HttpContext? httpContext = httpContextAccessor.HttpContext; + if (httpContext is not null && invokeResponse is not null) + { + httpContext.Response.StatusCode = invokeResponse.Status; + logger.LogDebug("Sending invoke response with status {Status}", invokeResponse.Status); + logger.LogTrace("Sending invoke response with status {Status} and Body {Body}", invokeResponse.Status, invokeResponse.Body); + if (invokeResponse.Body is not null) + await httpContext.Response.WriteAsJsonAsync(invokeResponse.Body, cancellationToken).ConfigureAwait(false); + } + } + }; + } + + // ==================== Proactive Messaging ==================== + + /// + /// Sends a text message proactively to a conversation. + /// + /// The conversation ID to send to. For channel threads, include ;messageid=. + /// The text to send. + /// The service URL. If null, uses the last-seen service URL from an incoming activity. + /// A cancellation token. + /// The response from the send operation. + public Task Send(string conversationId, string text, Uri? serviceUrl = null, CancellationToken cancellationToken = default) + { + Uri resolvedUrl = serviceUrl ?? _lastServiceUrl + ?? throw new InvalidOperationException("No service URL available. Either pass a serviceUrl parameter or ensure the bot has received at least one activity."); + + TeamsActivity activity = new TeamsActivityBuilder() + .WithType(TeamsActivityType.Message) + .WithServiceUrl(resolvedUrl) + .WithChannelId("msteams") + .WithConversation(new Core.Schema.Conversation { Id = conversationId }) + .WithText(text) + .Build(); + + return SendActivityAsync(activity, cancellationToken: cancellationToken); + } + + /// + /// Sends a text message proactively as a threaded reply. + /// Constructs a threaded conversation ID from the conversation ID and message ID. + /// + /// The conversation ID. + /// The thread root message ID. + /// The text to send. + /// A cancellation token. + /// The response from the send operation. + public Task Reply(string conversationId, string messageId, string text, CancellationToken cancellationToken = default) + { + string threadedConversationId = $"{conversationId};messageid={messageId}"; + return Send(threadedConversationId, text, cancellationToken: cancellationToken); + } +} diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs new file mode 100644 index 000000000..e26146070 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.OAuth; + +namespace Microsoft.Teams.Apps; + +/// +/// Options for configuring a . +/// +public sealed class TeamsBotApplicationOptions +{ + internal List OAuthFlows { get; } = []; + + /// + /// Register an OAuth flow with the given connection name and optional configuration. + /// + /// The OAuth connection name configured on the bot. + /// Optional delegate to configure the (card text, button text). + /// This instance for chaining. + public TeamsBotApplicationOptions AddOAuthFlow(string connectionName, Action? configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + + OAuthOptions options = new() { ConnectionName = connectionName }; + configure?.Invoke(options); + + OAuthFlows.Add(new OAuthFlowDescriptor(connectionName, options)); + return this; + } + + internal sealed record OAuthFlowDescriptor(string ConnectionName, OAuthOptions Options); +} diff --git a/core/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs b/core/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs new file mode 100644 index 000000000..e60530a4b --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core; + +namespace Microsoft.Teams.Apps; + +/// +/// Manages the send loop for Teams streaming messages. +/// Callers append raw deltas; the writer accumulates them and sends the full +/// text so far on each update. Every chunk — informative, intermediate, and +/// final — is sent as a new POST with a shared streamId +/// so Teams renders them as a single progressively-updating bubble. +/// +/// +/// Typical usage: +/// +/// var writer = TeamsStreamingWriter.CreateFromContext(context); +/// await writer.SendInformativeUpdateAsync("Thinking…"); //optional placeholder while the bot thinks +/// await writer.AppendResponseAsync(" Hello"); +/// await writer.AppendResponseAsync(", world"); +/// await writer.FinalizeResponseAsync(); // sends accumulated " Hello, world" +/// +/// +/// Entities and Attachments are only sent with the final message activity. +/// Pass them directly to : +/// +/// await writer.FinalizeResponseAsync( +/// entities: [new CitationEntity(...)], +/// attachments: [new TeamsAttachment(...)]); +/// +/// +public sealed class TeamsStreamingWriter +{ + // Teams streaming API enforces a rate limit; send intermediate updates at most once per interval. + private static readonly TimeSpan _minChunkInterval = TimeSpan.FromMilliseconds(500); + + private readonly ConversationClient _client; + private readonly TeamsActivity _reference; + private readonly string _conversationId; + private readonly ILogger _logger; + // Assigned from the server's 201 response after the first send; null until then. + private string? _streamId; + private int _sequence; + private bool _finalized; + private bool _cancelled; + private readonly System.Text.StringBuilder _accumulated = new(); + private DateTime _lastChunkSent = DateTime.MinValue; + + internal TeamsStreamingWriter(ConversationClient client, TeamsActivity reference, ILogger? logger = null) + { + _client = client; + _reference = reference; + _conversationId = reference.Conversation?.Id ?? throw new ArgumentException("Activity must have a Conversation with an Id.", nameof(reference)); + _logger = logger ?? NullLogger.Instance; + } + + /// + /// Creates a bound to the given context. + /// + public static TeamsStreamingWriter CreateFromContext(Context context) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(context); + return new TeamsStreamingWriter(context.TeamsBotApplication.ConversationClient, context.Activity); + } + + /// + /// Sends an informative placeholder (streamType = "informative"). + /// Optional — if omitted the first call begins the stream. + /// + public async Task SendInformativeUpdateAsync(string text, CancellationToken cancellationToken = default) + { + if (_lastChunkSent > DateTime.MinValue) + throw new InvalidOperationException("Cannot send an informative update after streaming has started."); + + _sequence++; + _logger.LogDebug("Sending informative streaming update (sequence {Sequence}).", _sequence); + SendActivityResponse? response = await _client.SendActivityAsync(BuildActivity(text, StreamType.Informative), cancellationToken: cancellationToken).ConfigureAwait(false); + _streamId ??= response?.Id; + _logger.LogDebug("Stream started with streamId '{StreamId}'.", _streamId); + } + + /// + /// Appends to the accumulated text and sends the + /// full accumulated text as an intermediate streaming update (streamType = "streaming"). + /// + /// Thrown if has already been called. + public async Task AppendResponseAsync(string chunk, CancellationToken cancellationToken = default) + { + if (_finalized) + throw new InvalidOperationException("Cannot append after FinalizeResponseAsync has been called."); + + if (_cancelled) + return; + + _accumulated.Append(chunk); + + if (DateTime.UtcNow - _lastChunkSent < _minChunkInterval) + { + _logger.LogTrace("Rate-limited: skipping intermediate send (interval {Interval}ms).", _minChunkInterval.TotalMilliseconds); + return; + } + + _sequence++; + try + { + _logger.LogDebug("Sending streaming chunk (sequence {Sequence}, accumulated {Length} chars).", _sequence, _accumulated.Length); + SendActivityResponse? response = await _client.SendActivityAsync(BuildActivity(_accumulated.ToString(), StreamType.Streaming), cancellationToken: cancellationToken).ConfigureAwait(false); + _streamId ??= response?.Id; + _lastChunkSent = DateTime.UtcNow; + } + catch (HttpRequestException ex) when ( + ex.StatusCode is HttpStatusCode.Gone or HttpStatusCode.NoContent + || ex.Message.Contains("Content stream was cancelled", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Stream cancelled by user (streamId '{StreamId}').", _streamId); + _cancelled = true; + } + } + + /// + /// Sends the accumulated text as the final update (streamType = "final") and marks the stream complete. + /// + /// Optional attachments to include in the final message activity. + /// Optional entities (e.g. citations, mentions) to include in the final message activity. + /// Whether to enable the feedback loop (thumbs up/down) on the final message. + /// Cancellation token. + /// Thrown if has already been called, or if no content has been accumulated via . + public async Task FinalizeResponseAsync(IList? attachments = null, IList? entities = null, bool feedbackEnabled = false, CancellationToken cancellationToken = default) + { + if (_finalized) + throw new InvalidOperationException("Cannot finalize after FinalizeResponseAsync has already been called."); + + if (_cancelled) + return; + + if (_accumulated.Length == 0 && (attachments == null || attachments.Count == 0)) + throw new InvalidOperationException("Cannot finalize with no content. Call AppendResponseAsync at least once before FinalizeResponseAsync."); + + _logger.LogDebug("Finalizing stream (streamId '{StreamId}', {Length} chars, {Sequences} sequences).", _streamId, _accumulated.Length, _sequence); + await _client.SendActivityAsync(BuildActivity(_accumulated.ToString(), StreamType.Final, attachments, entities, feedbackEnabled), cancellationToken: cancellationToken).ConfigureAwait(false); + + _finalized = true; + _logger.LogDebug("Stream finalized (streamId '{StreamId}').", _streamId); + } + + private TeamsActivity BuildActivity(string text, string streamType, IList? attachments = null, IList? entities = null, bool feedbackEnabled = false) + { + bool isFinal = streamType == StreamType.Final; + + TeamsActivityBuilder builder; + + if (isFinal) + { + StreamInfoEntity streamInfo = new() { StreamType = streamType }; + if (_streamId != null) + streamInfo.StreamId = _streamId; + + builder = new TeamsActivityBuilder(new MessageActivity(text)) + .WithConversationReference(_reference) + .AddEntity(streamInfo); + + if (entities != null) + foreach (Entity entity in entities) + builder.AddEntity(entity); + + if (attachments?.Count > 0) + builder.WithAttachments(attachments); + + TeamsActivity activity = builder.Build(); + if (feedbackEnabled) activity.AddFeedback(); + return activity; + } + else + { + StreamingActivity streaming = new(text); + streaming.StreamInfo.StreamType = streamType; + streaming.StreamInfo.StreamSequence = _sequence; + if (_streamId != null) + streaming.StreamInfo.StreamId = _streamId; + + builder = new TeamsActivityBuilder(streaming) + .WithConversationReference(_reference); + } + + return builder.Build(); + } +} diff --git a/core/src/Microsoft.Teams.Apps/version.json b/core/src/Microsoft.Teams.Apps/version.json new file mode 100644 index 000000000..c6f5a059e --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/version.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "2.1.0-alpha.{height}", + "publicReleaseRefSpec": [ + "^refs/heads/releases/core$" + ], + "cloudBuild": { + "buildNumber": { + "enabled": true + } + } +} \ No newline at end of file diff --git a/core/src/Microsoft.Teams.Core/BotApplication.cs b/core/src/Microsoft.Teams.Core/BotApplication.cs new file mode 100644 index 000000000..18d144af4 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/BotApplication.cs @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core.Hosting; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Core; + +/// +/// Represents a bot application that receives and processes activities from a messaging channel. +/// +/// +/// +/// is the central entry point for handling incoming bot activities. +/// Register it with the host using and +/// map it to an endpoint with . +/// +/// +/// Minimal setup in Program.cs: +/// +/// var builder = WebApplication.CreateBuilder(args); +/// builder.Services.AddBotApplication(); +/// +/// var app = builder.Build(); +/// var bot = app.UseBotApplication(); +/// +/// bot.OnActivity = async (activity, ct) => +/// { +/// await bot.SendActivityAsync( +/// CoreActivity.CreateBuilder() +/// .WithType(ActivityType.Message) +/// .WithConversation(activity.Conversation) +/// .WithServiceUrl(activity.ServiceUrl) +/// .WithProperty("text", "Hello!") +/// .Build(), +/// ct); +/// }; +/// +/// app.Run(); +/// +/// +/// +/// Subclassing for more complex scenarios: +/// +/// public class MyBot : BotApplication +/// { +/// public MyBot(ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger<MyBot> logger) +/// : base(conversationClient, userTokenClient, logger) +/// { +/// OnActivity = HandleActivityAsync; +/// } +/// +/// private async Task HandleActivityAsync(CoreActivity activity, CancellationToken ct) +/// { +/// if (activity.Type == ActivityType.Message) +/// { +/// // Echo the user's message back +/// await SendActivityAsync( +/// CoreActivity.CreateBuilder() +/// .WithType(ActivityType.Message) +/// .WithConversation(activity.Conversation) +/// .WithServiceUrl(activity.ServiceUrl) +/// .WithProperty("text", $"You said: {activity.Properties["text"]}") +/// .Build(), +/// ct); +/// } +/// } +/// } +/// +/// +/// +public class BotApplication +{ + private readonly ILogger _logger; + private readonly ConversationClient? _conversationClient; + private readonly UserTokenClient? _userTokenClient; + private readonly TimeSpan _processActivityTimeout = TimeSpan.FromMinutes(5); + internal TurnMiddleware MiddleWare { get; } + + /// + /// Creates a default instance, primarily for testing purposes. + /// The and properties will not be initialized; + /// accessing them will throw . + /// + protected BotApplication() + { + _logger = NullLogger.Instance; + AppId = string.Empty; + MiddleWare = new TurnMiddleware(); + } + + /// + /// Initializes a new instance of the class with the specified conversation client, user token client, + /// logger, and optional application options. + /// + /// The client used to manage and interact with conversations for the bot. + /// The client used to manage user tokens for authentication. + /// The logger used to record operational and diagnostic information for the bot application. + /// Options containing the application (client) ID, used for logging and diagnostics. Defaults to an empty instance if not provided. + public BotApplication(ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger logger, BotApplicationOptions? options = null) + { + options ??= new(); + _logger = logger; + AppId = options.AppId; + MiddleWare = new TurnMiddleware(); + MiddleWare.SetLogger(logger); + _conversationClient = conversationClient; + _userTokenClient = userTokenClient; + _processActivityTimeout = options.ProcessActivityTimeout; + logger.BotStarted(GetType().Name, options.AppId, Version); + } + + + /// + /// Gets the application (client) ID configured for this bot (for example, the Azure AD app registration client ID). + /// + public string AppId { get; } + + /// + /// Gets the used to send, update, and delete activities in conversations. + /// + /// This property is only available when the bot is constructed via dependency injection or + /// with an explicit . It throws + /// if accessed on a test instance created with the parameterless constructor. + public ConversationClient ConversationClient => _conversationClient ?? throw new InvalidOperationException("ConversationClient not initialized"); + + /// + /// Gets the used to manage OAuth user tokens (sign-in, sign-out, token exchange). + /// + /// This property is only available when the bot is constructed via dependency injection or + /// with an explicit . It throws + /// if accessed on a test instance created with the parameterless constructor. + public UserTokenClient UserTokenClient => _userTokenClient ?? throw new InvalidOperationException("UserTokenClient not registered"); + + /// + /// Gets or sets the delegate that is invoked to handle each incoming activity. + /// + /// + /// Assign a handler to process activities as they arrive. If , incoming activities + /// pass through the middleware pipeline but are otherwise ignored. + /// + /// + /// bot.OnActivity = async (activity, ct) => + /// { + /// if (activity.Type == ActivityType.Message) + /// { + /// await bot.SendActivityAsync( + /// CoreActivity.CreateBuilder() + /// .WithType(ActivityType.Message) + /// .WithConversation(activity.Conversation) + /// .WithServiceUrl(activity.ServiceUrl) + /// .WithProperty("text", "Received your message!") + /// .Build(), + /// ct); + /// } + /// }; + /// + /// + /// + public virtual Func? OnActivity { get; set; } + + /// + /// Processes an incoming HTTP request containing a bot activity. + /// + /// + /// + /// The request body is deserialized into a , run through the registered + /// middleware pipeline (see ), and finally dispatched to . + /// + /// + /// A dedicated internal timeout (configurable via , + /// default 5 minutes) is used instead of the HTTP request's cancellation token, because streaming handlers + /// may outlive the original HTTP connection. When a debugger is attached the timeout is disabled. + /// + /// + /// The HTTP context containing the incoming bot activity request. + /// A cancellation token that can be used to cancel the initial deserialization. Note: a dedicated timeout governs activity processing. + /// A task that represents the asynchronous activity processing operation. + /// Thrown if the request body cannot be deserialized into a valid activity. + /// Thrown if an error occurs while processing the activity, wrapping the original exception and the offending . + public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(_conversationClient); + + _logger.StartProcessingActivity(); + + CoreActivity activity = await CoreActivity.FromJsonStreamAsync(httpContext.Request.Body, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Invalid Activity"); + + string? correlationVector = httpContext.Request.GetCorrelationVector(); + _logger.ActivityReceived(activity.Type, activity.Id, activity.ServiceUrl, correlationVector); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.ReceivedActivityJson(activity.ToJson()); + } + + // TODO: Replace with structured scope data, ensure it works with OpenTelemetry and other logging providers + using (_logger.BeginActivityScope(activity.Type, activity.Id, activity.ServiceUrl, correlationVector)) + { + // Use a dedicated timeout instead of the HTTP request's cancellation token. + // The HTTP token fires when the client disconnects, which is expected for + // streaming handlers that outlive the original request. + using CancellationTokenSource cts = new(_processActivityTimeout); + try + { + CancellationToken token = Debugger.IsAttached ? CancellationToken.None : cts.Token; + await MiddleWare.RunPipelineAsync(this, activity, this.OnActivity, 0, token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + _logger.ActivityTimedOut(_processActivityTimeout, activity.Id); + } + catch (Exception ex) + { + _logger.ActivityProcessingError(ex, activity.Id); + throw new BotHandlerException("Error processing activity", ex, activity); + } + finally + { + _logger.ActivityProcessingFinished(activity.Id); + } + } + } + + /// + /// Adds the specified turn middleware to the middleware pipeline. + /// + /// + /// Middleware components execute in the order they are registered. Each middleware can inspect or modify + /// the activity, perform side effects (such as logging), or short-circuit the pipeline by not calling + /// . + /// + /// + /// bot.UseMiddleware(new MyLoggingMiddleware()); + /// bot.UseMiddleware(new MyAuthMiddleware()); + /// // Pipeline order: MyLoggingMiddleware → MyAuthMiddleware → OnActivity + /// + /// + /// + /// The middleware component to add to the pipeline. Cannot be null. + /// The instance representing the middleware pipeline. + public ITurnMiddleware UseMiddleware(ITurnMiddleware middleware) + { + ArgumentNullException.ThrowIfNull(middleware); + MiddleWare.Use(middleware); + return MiddleWare; + } + + /// + /// Sends the specified activity to the conversation asynchronously. + /// + /// + /// This is a convenience wrapper around . The activity + /// must have its and properties set. + /// + /// + /// var reply = CoreActivity.CreateBuilder() + /// .WithType(ActivityType.Message) + /// .WithConversation(incomingActivity.Conversation) + /// .WithServiceUrl(incomingActivity.ServiceUrl) + /// .WithProperty("text", "Hello from the bot!") + /// .Build(); + /// + /// SendActivityResponse? response = await bot.SendActivityAsync(reply, cancellationToken); + /// string? sentId = response?.Id; + /// + /// + /// + /// The activity to send. Cannot be null. Must have and set. + /// A cancellation token that can be used to cancel the send operation. + /// A task that represents the asynchronous operation. The task result contains a with the ID of the sent activity, or null. + /// Thrown if is null or 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 Microsoft.Teams.Core SDK (for example, "1.0.0"). + /// + public static string Version => ThisAssembly.NuGetPackageVersion; +} diff --git a/core/src/Microsoft.Teams.Core/BotHandlerException.cs b/core/src/Microsoft.Teams.Core/BotHandlerException.cs new file mode 100644 index 000000000..26d20ae17 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/BotHandlerException.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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; + } + + /// + /// Gets the bot activity associated with the exception, or null if no activity was provided. + /// + public CoreActivity? Activity { get; } +} diff --git a/core/src/Microsoft.Teams.Core/ConversationClient.Models.cs b/core/src/Microsoft.Teams.Core/ConversationClient.Models.cs new file mode 100644 index 000000000..5a7931f58 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/ConversationClient.Models.cs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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")] + public IList? Conversations { get; set; } +} + +/// +/// 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")] + public IList? Members { get; set; } +} + +/// +/// 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")] + public IList? Members { get; set; } + + /// + /// 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")] + public IList? Members { get; set; } +} + +/// +/// 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")] + public IList? Activities { get; set; } +} + +/// +/// 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.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Core/ConversationClient.cs new file mode 100644 index 000000000..035efbc5f --- /dev/null +++ b/core/src/Microsoft.Teams.Core/ConversationClient.cs @@ -0,0 +1,580 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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. +public class ConversationClient(HttpClient httpClient, ILogger logger = default!) +{ + private readonly BotHttpClient _botHttpClient = new(httpClient, logger); + private readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + internal const string ConversationHttpClientName = "BotConversationClient"; + + internal BotHttpClient BotHttpClient => _botHttpClient; + + /// + /// 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. Must contain a valid ServiceUrl and Conversation with an Id. + /// The recipient's IsTargeted property determines if this is a targeted activity, and AgenticIdentity is extracted from the recipient's properties. + /// 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 virtual async Task SendActivityAsync(CoreActivity activity, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + string? conversationId = activity.Conversation?.Id; + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + bool isTargeted = activity.Recipient?.IsTargeted == true; + AgenticIdentity? agenticIdentity = AgenticIdentity.FromAccount(activity.From); + + string url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/"; + + if (activity.ChannelId == "agents") + { + logger.TruncatingConversationId(); + string convId = "acf"; //conversationId.Length > 100 ? conversationId[..100] : conversationId; + url = $"{activity.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations/{Uri.EscapeDataString(convId)}/activities/"; + } + + if (isTargeted) + { + url += url.Contains('?', StringComparison.Ordinal) ? "&isTargetedActivity=true" : "?isTargetedActivity=true"; + } + + string body = activity.ToJson(); + + return await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "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. + /// Whether this is a targeted activity visible only to a specific recipient. + /// Optional agentic identity for authentication. + /// 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 virtual async Task UpdateActivityAsync(string conversationId, string activityId, CoreActivity activity, bool isTargeted = false, AgenticIdentity? agenticIdentity = null, 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/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}"; + + if (isTargeted) + { + url += "?isTargetedActivity=true"; + } + + string body = activity.ToJson(); + + logger.UpdatingActivity(url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Put, + url, + body, + CreateRequestOptions(agenticIdentity, "updating activity", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + + /// + /// Updates an existing targeted activity in a conversation. + /// The activity body is sent with the targeted recipient to avoid "Cannot edit Recipient of Targeted Message" errors. + /// + /// 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. Must contain a valid ServiceUrl. + /// Optional agentic identity for authentication. + /// 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 virtual async Task UpdateTargetedActivityAsync(string conversationId, string activityId, CoreActivity activity, AgenticIdentity? agenticIdentity = null, 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/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}?isTargetedActivity=true"; + + string body = activity.ToJson(); + + logger.UpdatingTargetedActivity(url, body); + + return (await _botHttpClient.SendAsync( + HttpMethod.Put, + url, + body, + CreateRequestOptions(agenticIdentity, "updating targeted activity", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Deletes an existing targeted 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 virtual Task DeleteTargetedActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + => DeleteActivityAsync(conversationId, activityId, serviceUrl, isTargeted: true, agenticIdentity, customHeaders, cancellationToken); + + /// + /// Deletes an existing activity from a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to delete. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// 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 virtual Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + => DeleteActivityAsync(conversationId, activityId, serviceUrl, isTargeted: false, agenticIdentity, customHeaders, cancellationToken); + + /// + /// Deletes an existing activity from a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to delete. Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// If true, deletes a targeted activity. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the delete operation. + /// A task that represents the asynchronous operation. + /// Thrown if the activity could not be deleted successfully. + public async Task DeleteActivityAsync(string conversationId, string activityId, Uri serviceUrl, bool isTargeted, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}"; + + if (isTargeted) + { + url += "?isTargetedActivity=true"; + } + + 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 ID of the conversation. + /// The activity to delete. Must contain valid Id and ServiceUrl. Cannot be null. + /// Whether this is a targeted activity. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the delete operation. + /// A task that represents the asynchronous operation. + /// Thrown if the activity could not be deleted successfully. + public virtual async Task DeleteActivityAsync(string conversationId, CoreActivity activity, bool isTargeted = false, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentException.ThrowIfNullOrWhiteSpace(activity.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(activity.ServiceUrl); + + await DeleteActivityAsync( + conversationId, + activity.Id, + activity.ServiceUrl, + isTargeted, + agenticIdentity, + 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 virtual 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/{Uri.EscapeDataString(conversationId)}/members"; + + 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 with strongly-typed result. + /// + /// The type of conversation account to return. Must inherit from . + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the user to retrieve. 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 the conversation member + /// of type T with detailed information about the user. + /// + /// Thrown if the member could not be retrieved successfully. + public virtual 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/{Uri.EscapeDataString(conversationId)}/members/{Uri.EscapeDataString(userId)}"; + + 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 virtual 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)}"; + } + + 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 virtual 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/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}/members"; + + 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 virtual 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"; + + string paramsJson = JsonSerializer.Serialize(parameters, _jsonSerializerOptions); + + logger.CreatingConversation(url, paramsJson); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + paramsJson, + 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 virtual 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/{Uri.EscapeDataString(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)}"; + } + + 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 virtual 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/{Uri.EscapeDataString(conversationId)}/members/{Uri.EscapeDataString(memberId)}"; + + 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 virtual 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/{Uri.EscapeDataString(conversationId)}/activities/history"; + + string transcriptJson = JsonSerializer.Serialize(transcript, _jsonSerializerOptions); + logger.SendingConversationHistory(url, transcriptJson); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + transcriptJson, + 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 virtual 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/{Uri.EscapeDataString(conversationId)}/attachments"; + + string attachmentDataJson = JsonSerializer.Serialize(attachmentData, _jsonSerializerOptions); + logger.UploadingAttachment(url, attachmentDataJson); + + return (await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + attachmentDataJson, + CreateRequestOptions(agenticIdentity, "uploading attachment", customHeaders), + cancellationToken).ConfigureAwait(false))!; + } + + /// + /// Adds a reaction to an activity in a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to react to. Cannot be null or whitespace. + /// The type of reaction to add (e.g., "like", "heart", "laugh"). Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the reaction could not be added successfully. + public async Task AddReactionAsync(string conversationId, string activityId, string reactionType, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentException.ThrowIfNullOrWhiteSpace(reactionType); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}/reactions/{Uri.EscapeDataString(reactionType)}"; + + await _botHttpClient.SendAsync( + HttpMethod.Put, + url, + body: null, + CreateRequestOptions(agenticIdentity, "adding reaction", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Removes a reaction from an activity in a conversation. + /// + /// The ID of the conversation. Cannot be null or whitespace. + /// The ID of the activity to remove the reaction from. Cannot be null or whitespace. + /// The type of reaction to remove (e.g., "like", "heart", "laugh"). Cannot be null or whitespace. + /// The service URL for the conversation. Cannot be null. + /// Optional agentic identity for authentication. + /// Optional custom headers to include in the request. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. + /// Thrown if the reaction could not be removed successfully. + public async Task DeleteReactionAsync(string conversationId, string activityId, string reactionType, Uri serviceUrl, AgenticIdentity? agenticIdentity = null, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentException.ThrowIfNullOrWhiteSpace(reactionType); + ArgumentNullException.ThrowIfNull(serviceUrl); + + string url = $"{serviceUrl.ToString().TrimEnd('/')}/v3/conversations/{Uri.EscapeDataString(conversationId)}/activities/{Uri.EscapeDataString(activityId)}/reactions/{Uri.EscapeDataString(reactionType)}"; + + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "deleting reaction", customHeaders), + cancellationToken).ConfigureAwait(false); + } + + private BotRequestOptions CreateRequestOptions(AgenticIdentity? agenticIdentity, string operationDescription, CustomHeaders? customHeaders) => + new() + { + AgenticIdentity = agenticIdentity, + OperationDescription = operationDescription, + DefaultHeaders = DefaultCustomHeaders, + CustomHeaders = customHeaders + }; +} diff --git a/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs b/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs new file mode 100644 index 000000000..bc07120fc --- /dev/null +++ b/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Usage", + "CA2227:Collection properties should be read only", + Justification = "Required for serialization", + Scope = "namespaceanddescendants", + Target = "~N:Microsoft.Teams.Core")] diff --git a/core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs new file mode 100644 index 000000000..80f87b4ef --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; + +namespace Microsoft.Teams.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 +{ + /// + /// Configures the default to handle bot messages at the specified route. + /// + /// The endpoint route builder used to configure endpoints. + /// The route path at which to listen for incoming bot messages. Defaults to "api/messages". + /// The registered instance. + public static BotApplication UseBotApplication( + this IEndpointRouteBuilder endpoints, + string routePath = "api/messages") + => UseBotApplication(endpoints, routePath); + + /// + /// 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 HTTP 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 endpoint route builder used to configure endpoints. + /// 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 IEndpointRouteBuilder endpoints, + string routePath = "api/messages") + where TApp : BotApplication + { + ArgumentNullException.ThrowIfNull(endpoints); + + // Add authentication and authorization middleware to the pipeline + // This is safe because WebApplication implements both IEndpointRouteBuilder and IApplicationBuilder + if (endpoints is IApplicationBuilder app) + { + app.UseAuthentication(); + app.UseAuthorization(); + } + + TApp botApp = endpoints.ServiceProvider.GetService() ?? throw new InvalidOperationException("Application not registered"); + + endpoints.MapPost(routePath, (HttpContext httpContext, CancellationToken cancellationToken) + => botApp.ProcessAsync(httpContext, cancellationToken) + ).RequireAuthorization(); + + return botApp; + } + + /// + /// Registers the default bot application and its dependencies in the service collection. + /// + /// The service collection to add services to. + /// The configuration section name containing Azure AD settings. Defaults to "AzureAd". + /// The service collection for method chaining. + public static IServiceCollection AddBotApplication(this IServiceCollection services, string sectionName = "AzureAd") + => services.AddBotApplication(sectionName); + + /// + /// Registers a custom bot application and its dependencies in the service collection. + /// + /// The custom bot application type that inherits from BotApplication. + /// The service collection to add services to. + /// The configuration section name containing Azure AD settings. Defaults to "AzureAd". + /// The service collection for method chaining. + public static IServiceCollection AddBotApplication(this IServiceCollection services, string sectionName = "AzureAd") where TApp : BotApplication + { + BotConfig botConfig = BotConfig.Resolve(services, sectionName); + + services.AddBotApplication(botConfig); + + return services; + } + + /// + /// Registers a custom bot application and its dependencies in the service collection. + /// + /// The custom bot application type that inherits from BotApplication. + /// The service collection to add services to. + /// The configuration containing Azure AD settings. + /// The service collection for method chaining. + internal static IServiceCollection AddBotApplication(this IServiceCollection services, BotConfig botConfig) where TApp : BotApplication + { + services.AddSingleton(sp => + { + IConfiguration config = sp.GetRequiredService(); + return new BotApplicationOptions + { + AppId = botConfig.ClientId + }; + }); + services.AddHttpContextAccessor(); + services.AddBotAuthorization(botConfig); + services.AddConversationClient(botConfig); + services.AddUserTokenClient(botConfig); + services.AddSingleton(); + return services; + } + + /// + /// Registers the and its dependencies in the service collection. + /// + /// The service collection to add services to. + /// The configuration section name containing Azure AD settings. Defaults to "AzureAd". + /// The service collection for method chaining. + public static IServiceCollection AddConversationClient(this IServiceCollection services, string sectionName = "AzureAd") + { + BotConfig botConfig = BotConfig.Resolve(services, sectionName); + return services.AddConversationClient(botConfig); + } + + /// + /// Registers the and its dependencies in the service collection. + /// + /// The service collection to add services to. + /// The configuration section name containing Azure AD settings. Defaults to "AzureAd". + /// The service collection for method chaining. + public static IServiceCollection AddUserTokenClient(this IServiceCollection services, string sectionName = "AzureAd") + { + BotConfig botConfig = BotConfig.Resolve(services, sectionName); + return services.AddUserTokenClient(botConfig); + } + + /// + /// Adds conversation client to the service collection using an already-resolved BotConfig. + /// + private static IServiceCollection AddConversationClient(this IServiceCollection services, BotConfig botConfig) => + services.AddBotClient(ConversationClient.ConversationHttpClientName, botConfig); + + /// + /// Adds user token client to the service collection using an already-resolved BotConfig. + /// + private static IServiceCollection AddUserTokenClient(this IServiceCollection services, BotConfig botConfig) => + services.AddBotClient(UserTokenClient.UserTokenHttpClientName, botConfig); + + internal static IServiceCollection AddBotClient( + this IServiceCollection services, + string httpClientName, + BotConfig botConfig) where TClient : class + { + // Register options using values from BotConfig + services.AddOptions() + .Configure(options => + { + options.Scope = botConfig.Scope; + options.SectionName = botConfig.SectionName; + }); + + // TODO: This shouldn't be called multiple times. It will being called once for each client we support. + services + .AddHttpClient() + .AddTokenAcquisition(true) + .AddInMemoryTokenCaches() + .AddAgentIdentities(); + + ILogger logger = GetLoggerFromServices(services); + + if (services.ConfigureMSAL(botConfig, logger)) + { + services.AddHttpClient(httpClientName) + .AddHttpMessageHandler(sp => + { + BotClientOptions botOptions = sp.GetRequiredService>().Value; + return new BotAuthenticationHandler( + sp.GetRequiredService(), + sp.GetRequiredService>(), + botOptions.Scope, + sp.GetService>()); + }); + } + else + { + services.AddHttpClient(httpClientName); + } + + return services; + } + + /// + /// Gets a logger instance from the service collection. + /// If the logger factory is not available as an instance, builds a temporary service provider to create the logger. + /// + /// The service collection to extract the logger from. + /// The type to use for the logger category. If null, uses AddBotApplicationExtensions. + /// An ILogger instance, or NullLogger if no logger factory is registered. + internal static ILogger GetLoggerFromServices(IServiceCollection services, Type? categoryType = null) + { + ServiceDescriptor? loggerFactoryDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ILoggerFactory)); + ILoggerFactory? loggerFactory = loggerFactoryDescriptor?.ImplementationInstance as ILoggerFactory; + + // If logger factory is available as an instance, use it directly + if (loggerFactory != null) + { + return loggerFactory.CreateLogger(categoryType ?? typeof(AddBotApplicationExtensions)); + } + + // Logger factory not available as a direct instance; return NullLogger + // to avoid building a throwaway ServiceProvider during DI configuration. + return Extensions.Logging.Abstractions.NullLogger.Instance; + } +} diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotApplicationOptions.cs b/core/src/Microsoft.Teams.Core/Hosting/BotApplicationOptions.cs new file mode 100644 index 000000000..bc2221bb5 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Hosting/BotApplicationOptions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Core.Hosting; + +/// +/// Options for configuring a bot application instance. +/// +public sealed class BotApplicationOptions +{ + /// + /// Gets or sets the application (client) ID, used for logging and diagnostics. + /// + public string AppId { get; set; } = string.Empty; + + /// + /// Gets or sets the maximum time allowed for processing an incoming activity. + /// This timeout replaces the HTTP request's cancellation token so that handlers + /// (especially streaming handlers) are not canceled when the incoming HTTP connection closes. + /// Defaults to 5 minutes. Set to to disable the timeout. + /// + public TimeSpan ProcessActivityTimeout { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs new file mode 100644 index 000000000..08e66b1f7 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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. +/// The name of the MSAL configuration options to use for token acquisition. Defaults to . +internal sealed class BotAuthenticationHandler( + IAuthorizationHeaderProvider authorizationHeaderProvider, + ILogger logger, + string scope, + IOptions? managedIdentityOptions = null, + string? authenticationOptionsName = 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 AgenticAppId {AgenticAppId}"); + private static readonly Action _logAppOnlyToken = + LoggerMessage.Define(LogLevel.Debug, new(3), "Acquiring app-only token for scope: {Scope}"); + private static readonly Action _logTokenClaims = + LoggerMessage.Define(LogLevel.Trace, new(4), "Acquired token claims:{Claims}"); + private static readonly Action _logInvalidAgenticUserId = + LoggerMessage.Define(LogLevel.Warning, new(5), "Invalid AgenticUserId '{AgenticUserId}'; falling back to app-only token."); + private static readonly Action _logTokenParseFailure = + LoggerMessage.Define(LogLevel.Warning, new(6), "Failed to parse JWT token for trace logging."); + + /// + /// 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; + + LogTokenClaims(tokenValue); + + 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 = authenticationOptionsName ?? MsalConfigurationExtensions.MsalConfigKey, + } + }; + + // Conditionally apply ManagedIdentity configuration if registered + if (_managedIdentityOptions is not null) + { + ManagedIdentityOptions miOptions = _managedIdentityOptions.Value; + + if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) + { + _logger.ApplyingManagedIdentity(miOptions.UserAssignedClientId); + options.AcquireTokenOptions.ManagedIdentity = miOptions; + } + } + + if (agenticIdentity is not null && + !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) && + !string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) + { + _logAgenticToken(_logger, agenticIdentity.AgenticAppId, null); + + if (!Guid.TryParse(agenticIdentity.AgenticUserId, out Guid agenticUserGuid)) + { + _logInvalidAgenticUserId(_logger, agenticIdentity.AgenticUserId, null); + } + else + { + options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, agenticUserGuid); + 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; + } + + private void LogTokenClaims(string token) + { + if (!_logger.IsEnabled(LogLevel.Trace)) + { + return; + } + + + try + { + JwtSecurityToken jwtToken = new(token); + string claims = Environment.NewLine + string.Join(Environment.NewLine, jwtToken.Claims.Select(c => $" {c.Type}: {c.Value}")); + _logTokenClaims(_logger, claims, null); + } + catch (ArgumentException ex) + { + _logTokenParseFailure(_logger, ex); + } + } +} diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotClientOptions.cs b/core/src/Microsoft.Teams.Core/Hosting/BotClientOptions.cs new file mode 100644 index 000000000..550b743dc --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Hosting/BotClientOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Core.Hosting; + +/// +/// Options for configuring bot client HTTP clients. +/// +internal sealed class BotClientOptions +{ + /// + /// Gets or sets the scope for bot authentication. + /// + public string Scope { get; set; } = "https://api.botframework.com/.default"; + + /// + /// Gets or sets the configuration section name. + /// + public string SectionName { get; set; } = "AzureAd"; +} diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs new file mode 100644 index 000000000..480072fd5 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Teams.Core.Hosting; + +/// +/// Configuration model for bot authentication credentials. +/// +/// +/// This class consolidates bot authentication settings from various configuration sources including +/// Bot Framework SDK configuration, Core configuration, and MSAL configuration sections. +/// It supports multiple authentication modes: client secrets, system-assigned managed identities, +/// user-assigned managed identities, and federated identity credentials (FIC). +/// +internal sealed class BotConfig +{ + /// + /// Identifier used to specify system-assigned managed identity authentication. + /// When FicClientId equals this value, the system will use the system-assigned managed identity. + /// + public const string SystemManagedIdentityIdentifier = "system"; + + private const string BotScope = "https://api.botframework.com/.default"; + + private const string DefaultSectionName = "AzureAd"; + + /// + /// Gets or sets the Azure AD tenant ID. + /// + public string TenantId { get; set; } = string.Empty; + + /// + /// Gets or sets the application (client) ID from Azure AD app registration. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the client secret for client credentials authentication. + /// Optional if using managed identity or federated identity credentials. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the client ID for federated identity credentials or user-assigned managed identity. + /// Use to specify system-assigned managed identity. + /// + public string? FicClientId { get; set; } + + /// + /// Gets or sets the configuration section name used to resolve this BotConfig. + /// + public string SectionName { get; set; } = DefaultSectionName; + + /// + /// Gets or sets the scope for token acquisition. + /// Defaults to "https://api.botframework.com/.default" if not specified. + /// + public string Scope { get; set; } = BotScope; + + internal IConfigurationSection? MsalConfigurationSection { get; set; } + + /// + /// Creates a BotConfig from Bot Framework SDK configuration format. + /// + /// Configuration containing MicrosoftAppId, MicrosoftAppPassword, and MicrosoftAppTenantId settings. + /// A new BotConfig instance with settings from Bot Framework configuration. + /// Thrown when is null. + public static BotConfig FromBFConfig(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return new() + { + TenantId = configuration["MicrosoftAppTenantId"] ?? string.Empty, + ClientId = configuration["MicrosoftAppId"] ?? string.Empty, + ClientSecret = configuration["MicrosoftAppPassword"], + Scope = configuration["Scope"] ?? BotScope + }; + } + + /// + /// Creates a BotConfig from Teams Bot Core environment variable format. + /// + /// Configuration containing TENANT_ID, CLIENT_ID, CLIENT_SECRET, and MANAGED_IDENTITY_CLIENT_ID settings. + /// A new BotConfig instance with settings from Core configuration. + /// Thrown when is null. + /// + /// This format is typically used with environment variables in containerized deployments. + /// The MANAGED_IDENTITY_CLIENT_ID can be set to "system" for system-assigned managed identity. + /// + 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"], + Scope = configuration["Scope"] ?? BotScope, + }; + } + + /// + /// Creates a BotConfig from MSAL configuration section format. + /// + /// Configuration containing an MSAL configuration section. + /// The name of the configuration section containing MSAL settings. Defaults to "AzureAd". + /// A new BotConfig instance with settings from the MSAL configuration section. + /// Thrown when is null. + /// + /// This format is compatible with Microsoft.Identity.Web configuration sections in appsettings.json. + /// The section should contain TenantId, ClientId, and optionally ClientSecret properties. + /// + public static BotConfig FromMsalConfig(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"], + Scope = section["Scope"] ?? BotScope, + MsalConfigurationSection = section, + SectionName = sectionName + }; + } + + /// + /// Resolves a BotConfig from a service collection by extracting configuration and logger, + /// then trying all configuration formats in priority order. + /// + /// The service collection containing IConfiguration and ILoggerFactory registrations. + /// The MSAL configuration section name. Defaults to "AzureAd". + /// The first BotConfig with a non-empty ClientId, or a BotConfig with empty ClientId if none is found. + public static BotConfig Resolve(IServiceCollection services, string sectionName = "AzureAd") + { + ArgumentNullException.ThrowIfNull(services); + + // Extract IConfiguration from service collection — prefer the instance if available, + // otherwise resolve via the factory registered in the descriptor. + ServiceDescriptor? configDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IConfiguration)); + IConfiguration? configuration = configDescriptor?.ImplementationInstance as IConfiguration; + if (configuration is null && configDescriptor?.ImplementationFactory is not null) + { + using ServiceProvider tempProvider = services.BuildServiceProvider(); + configuration = tempProvider.GetService(); + } + + if (configuration is null) + { + throw new InvalidOperationException( + "IConfiguration must be registered in the service collection before calling BotConfig.Resolve. " + + "Ensure AddConfiguration() or WebApplication.CreateBuilder() has been called."); + } + + // Get logger using the helper method from AddBotApplicationExtensions + ILogger logger = AddBotApplicationExtensions.GetLoggerFromServices(services, typeof(BotConfig)); + + return Resolve(configuration, sectionName, logger); + } + + /// + /// Resolves a BotConfig by trying all configuration formats in priority order: + /// MSAL section, Core environment variables, then Bot Framework SDK keys. + /// + /// The application configuration. + /// The MSAL configuration section name. Defaults to "AzureAd". + /// Optional logger to log which configuration source was used. + /// The first BotConfig with a non-empty ClientId, or a BotConfig with empty ClientId if none is found. + public static BotConfig Resolve(IConfiguration configuration, string sectionName = "AzureAd", ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(configuration); + logger ??= NullLogger.Instance; + + BotConfig config = FromMsalConfig(configuration, sectionName); + if (!string.IsNullOrEmpty(config.ClientId)) + { + _logUsingSectionConfig(logger, sectionName, null); + config.SectionName = sectionName; + return config; + } + + config = FromCoreConfig(configuration); + if (!string.IsNullOrEmpty(config.ClientId)) + { + _logUsingCoreConfig(logger, null); + config.SectionName = sectionName; + return config; + } + + config = FromBFConfig(configuration); + if (!string.IsNullOrEmpty(config.ClientId)) + { + _logUsingBFConfig(logger, null); + config.SectionName = sectionName; + return config; + } + + // No configuration found - log warning and return empty config + _logNoConfigFound(logger, null); + return new BotConfig { SectionName = sectionName }; + } + + private static readonly Action _logUsingBFConfig = + LoggerMessage.Define(LogLevel.Debug, new(1), "Resolved bot configuration from Bot Framework configuration keys"); + private static readonly Action _logUsingCoreConfig = + LoggerMessage.Define(LogLevel.Debug, new(2), "Resolved bot configuration from Core environment variables"); + private static readonly Action _logUsingSectionConfig = + LoggerMessage.Define(LogLevel.Debug, new(3), "Resolved bot configuration from '{SectionName}' configuration section"); + private static readonly Action _logNoConfigFound = + LoggerMessage.Define(LogLevel.Warning, new(4), "No bot configuration found in configuration."); + +} diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs new file mode 100644 index 000000000..16801f81b --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs @@ -0,0 +1,351 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; + +namespace Microsoft.Teams.Core.Hosting +{ + /// + /// Provides extension methods for configuring JWT authentication and authorization for bots and agents. + /// + public static class JwtExtensions + { + internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration"; + internal const string EntraOIDC = "https://login.microsoftonline.com/"; + + /// + /// Adds JWT authentication for bots and agents using configuration from appsettings. + /// + /// The service collection to add authentication to. + /// 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, string aadSectionName = "AzureAd", ILogger? logger = null) + { + BotConfig botConfig = ResolveBotConfig(services, aadSectionName); + return services.AddBotAuthentication(botConfig.ClientId, botConfig.TenantId, aadSectionName, logger); + } + + /// + /// Adds JWT authentication for bots and agents with manually provided configuration values. + /// + /// The service collection to add authentication to. + /// The application (client) ID for token validation. + /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. + /// The authentication scheme name. Defaults to "AzureAd". + /// Optional logger instance for logging. If null, a NullLogger will be used. + /// An for further authentication configuration. + public static AuthenticationBuilder AddBotAuthentication( + this IServiceCollection services, + string clientId, + string tenantId = "", + string schemeName = "AzureAd", + ILogger? logger = null) + { + AuthenticationBuilder builder = services.AddAuthentication(); + builder.AddBotAuthentication(clientId, tenantId, schemeName, logger); + return builder; + } + + /// + /// Adds JWT authentication for bots and agents to an existing authentication builder. + /// Use this overload when registering multiple authentication schemes to avoid calling AddAuthentication() multiple times. + /// + /// The existing authentication builder. + /// The application (client) ID for token validation. + /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. + /// The authentication scheme name. + /// Optional logger instance for logging. If null, a NullLogger will be used. + /// The for chaining. + public static AuthenticationBuilder AddBotAuthentication( + this AuthenticationBuilder builder, + string clientId, + string tenantId = "", + string schemeName = "AzureAd", + ILogger? logger = null) + { + if (string.IsNullOrWhiteSpace(clientId)) + { + builder.AddBypassAuthentication(schemeName, logger); + } + else + { + builder.AddTeamsJwtBearer(schemeName, clientId, tenantId, logger); + } + return builder; + } + + /// + /// Adds authorization policies to the service collection using configuration from appsettings. + /// + /// The service collection to add authorization to. + /// The configuration section name for the settings. Defaults to "AzureAd". + /// Optional logger instance for logging. If null, a NullLogger will be used. + /// An for further authorization configuration. + public static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, string aadSectionName = "AzureAd", ILogger? logger = null) + { + logger ??= NullLogger.Instance; + + BotConfig botConfig = ResolveBotConfig(services, aadSectionName); + return services.AddBotAuthorization(botConfig, logger); + } + + /// + /// Adds authorization policies to the service collection using configuration from appsettings. + /// + /// The service collection to add authorization to. + /// The bot configuration settings. + /// Optional logger instance for logging. If null, a NullLogger will be used. + /// An for further authorization configuration. + internal static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, BotConfig botConfig, ILogger? logger = null) + { + logger ??= NullLogger.Instance; + + return services.AddBotAuthorization(botConfig.ClientId, botConfig.TenantId, botConfig.SectionName, logger); + } + + /// + /// Adds authorization policies to the service collection with manually provided configuration values. + /// + /// The service collection to add authorization to. + /// The application (client) ID for token validation. + /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. + /// The authentication scheme name. Defaults to "AzureAd". + /// Optional logger instance for logging. If null, a NullLogger will be used. + /// An for further authorization configuration. + public static AuthorizationBuilder AddBotAuthorization( + this IServiceCollection services, + string clientId, + string tenantId = "", + string schemeName = "AzureAd", + ILogger? logger = null) + { + services.AddBotAuthentication(clientId, tenantId, schemeName, logger); + + return services + .AddAuthorizationBuilder() + .AddDefaultPolicy(schemeName, policy => + { + policy.AuthenticationSchemes.Add(schemeName); + policy.RequireAuthenticatedUser(); + }); + } + + private static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId) + { + // Bot Framework tokens + if (issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)) + return issuer; + + // Entra tokens � bot-to-bot (agent) and user (tab/API) + // Use the token's own tid claim for multi-tenant; fall back to configured tenant + (_, string? tid) = GetTokenClaims(token); + string? effectiveTenant = string.IsNullOrEmpty(configuredTenantId) ? tid : configuredTenantId; + + if (effectiveTenant is not null && + (issuer == $"https://login.microsoftonline.com/{effectiveTenant}/v2.0" || + issuer == $"https://sts.windows.net/{effectiveTenant}/")) + return issuer; + + throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid."); + } + + private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) => + token is JsonWebToken jwt + ? (jwt.Issuer, jwt.TryGetClaim("tid", out Claim? c) ? c.Value : null) + : (null, null); + + /// + /// Adds Teams JWT Bearer authentication that supports both Bot Framework and Entra ID tokens. + /// + /// The authentication builder. + /// The authentication scheme name. + /// The application (client) ID used to validate the audience of tokens. + /// The Azure AD tenant ID. + /// Optional logger for authentication events. + /// The authentication builder for chaining. + /// + /// This method configures authentication to support both types of tokens: + /// + /// Bot Framework tokens: Issued by the Bot Connector service when channels send activities to your bot (issuer: https://api.botframework.com). + /// Entra ID tokens: Issued by Azure AD when the bot is registered as an agentic application (issuer: https://login.microsoftonline.com). See https://learn.microsoft.com/en-us/microsoft-agent-365/developer/identity#understanding-agent-identity-components + /// + /// The signing keys for both token types are dynamically resolved at runtime using OpenID Connect discovery, + /// allowing the same authentication configuration to validate tokens from multiple issuers. + /// + private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger = null) + { + // One ConfigurationManager per OIDC authority, shared safely across all requests. + ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase); + + // Cache resolved configurations to avoid blocking async calls on every token validation. + // ConfigurationManager handles background refresh internally; we cache the Task so that + // only the first call per authority actually blocks. + ConcurrentDictionary> configCache = new(StringComparer.OrdinalIgnoreCase); + + builder.AddJwtBearer(schemeName, jwtOptions => + { + jwtOptions.SaveToken = true; + jwtOptions.IncludeErrorDetails = true; + jwtOptions.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidAudiences = [audience, $"api://{audience}"], + IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId), + IssuerSigningKeyResolver = (_, securityToken, _, _) => + { + (string? iss, string? tid) = GetTokenClaims(securityToken); + if (iss is null) return []; + + string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase) + ? BotOIDC + : $"{EntraOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; + + logger?.ResolvingSigningKeys(authority, iss); + + ConfigurationManager manager = configManagerCache.GetOrAdd(authority, a => + new ConfigurationManager( + a, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever { RequireHttps = jwtOptions.RequireHttpsMetadata })); + + // Cache the Task so only the first call per authority blocks; + // subsequent calls return the already-completed task synchronously. + // ConfigurationManager handles background refresh of stale configs internally. + Task configTask = configCache.GetOrAdd(authority, + _ => manager.GetConfigurationAsync(CancellationToken.None)); + + OpenIdConnectConfiguration config = configTask.ConfigureAwait(false).GetAwaiter().GetResult(); + return config.SigningKeys; + } + }; + jwtOptions.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + jwtOptions.MapInboundClaims = true; + jwtOptions.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + ILogger log = GetLogger(context.HttpContext, logger); + log.TokenValidated(schemeName); + if (log.IsEnabled(LogLevel.Trace) && context.SecurityToken is JsonWebToken jwt) + { + string claims = Environment.NewLine + string.Join(Environment.NewLine, jwt.Claims.Select(c => $" {c.Type}: {c.Value}")); + log.IncomingTokenClaims(claims); + } + return Task.CompletedTask; + }, + OnForbidden = context => + { + GetLogger(context.HttpContext, logger).ForbiddenForScheme(schemeName); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + ILogger log = GetLogger(context.HttpContext, logger); + + string? tokenIssuer = null; + string? tokenAudience = null; + string? tokenExpiration = null; + string? tokenSubject = null; + string authHeader = context.Request.Headers.Authorization.ToString(); + if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + try + { + JsonWebToken jwt = new(authHeader["Bearer ".Length..].Trim()); + (tokenIssuer, _) = GetTokenClaims(jwt); + tokenAudience = jwt.GetClaim("aud")?.Value; + tokenExpiration = jwt.ValidTo.ToString("o"); + tokenSubject = jwt.Subject; + } + catch (ArgumentException) { } + } + + TokenValidationParameters? validationParams = context.Options?.TokenValidationParameters; + string expectedAudiences = validationParams?.ValidAudiences is not null + ? string.Join(", ", validationParams.ValidAudiences) + : validationParams?.ValidAudience ?? "n/a"; + log.JwtAuthenticationFailed( + context.Exception, + schemeName, + context.Exception.Message, + tokenIssuer ?? "n/a", + tokenAudience ?? "n/a", + tokenExpiration ?? "n/a", + tokenSubject ?? "n/a", + expectedAudiences); + + return Task.CompletedTask; + } + }; + jwtOptions.Validate(); + }); + return builder; + } + + private static AuthenticationBuilder AddBypassAuthentication(this AuthenticationBuilder builder, string schemeName, ILogger? logger = null) + { + (logger ?? NullLogger.Instance).BypassAuthenticationConfigured(schemeName); + + builder.AddJwtBearer(schemeName, jwtOptions => + { +#pragma warning disable CA5404 // Do not disable token validation checks + jwtOptions.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + ValidateIssuerSigningKey = false, + RequireSignedTokens = false, + SignatureValidator = (token, _) => new JsonWebToken(token) + }; +#pragma warning restore CA5404 // Do not disable token validation checks + jwtOptions.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + // Always succeed authentication even without a token + GetLogger(context.HttpContext, logger).BypassAuthenticationSucceeded(schemeName); + context.NoResult(); + context.Principal = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity("BypassAuth")); + context.Success(); + return Task.CompletedTask; + } + }; + }); + return builder; + } + + private static BotConfig ResolveBotConfig(IServiceCollection services, string sectionName) + { + ServiceDescriptor? configDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IConfiguration)); + IConfiguration configuration = configDescriptor?.ImplementationInstance as IConfiguration + ?? services.BuildServiceProvider().GetRequiredService(); + + return BotConfig.Resolve(configuration, sectionName); + } + + private static ILogger GetLogger(HttpContext context, ILogger? fallback) => + context.RequestServices.GetService()?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") + ?? fallback + ?? NullLogger.Instance; + } +} diff --git a/core/src/Microsoft.Teams.Core/Hosting/MsalConfigurationExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/MsalConfigurationExtensions.cs new file mode 100644 index 000000000..4baa223c8 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Hosting/MsalConfigurationExtensions.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Teams.Core.Hosting; + +/// +/// Provides extension methods for configuring MSAL (Microsoft Authentication Library) with different credential types. +/// +internal static class MsalConfigurationExtensions +{ + internal const string MsalConfigKey = "AzureAd"; + + /// + /// Configures MSAL authentication based on the provided BotConfig. + /// + /// The service collection to configure. + /// The bot configuration containing authentication settings. + /// Logger for configuration messages. + /// True if MSAL was configured, false if ClientId is not present. + internal static bool ConfigureMSAL(this IServiceCollection services, BotConfig botConfig, ILogger logger) + { + ArgumentNullException.ThrowIfNull(botConfig); + + if (string.IsNullOrWhiteSpace(botConfig.ClientId)) + { + // Don't configure MSAL if ClientId is not present + return false; + } + else if (botConfig.MsalConfigurationSection != null) + { + services.ConfigureMSALFromConfig(botConfig.MsalConfigurationSection); + } + else + { + services.ConfigureMSALFromBotConfig(botConfig, logger); + } + + 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 => + { + 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 => + { + 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 => + { + 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; + } + + private static bool IsSystemAssignedManagedIdentity(string? clientId) + => string.Equals(clientId, BotConfig.SystemManagedIdentityIdentifier, StringComparison.OrdinalIgnoreCase); + + private static readonly Action _logUsingClientSecret = + LoggerMessage.Define(LogLevel.Debug, new(1), "Configuring authentication with client secret"); + private static readonly Action _logUsingUMI = + LoggerMessage.Define(LogLevel.Debug, new(2), "Configuring authentication with User-Assigned Managed Identity"); + private static readonly Action _logUsingFIC = + LoggerMessage.Define(LogLevel.Debug, new(3), "Configuring authentication with Federated Identity Credential (Managed Identity) with {IdentityType} Managed Identity"); +} diff --git a/core/src/Microsoft.Teams.Core/Http/BotHttpClient.cs b/core/src/Microsoft.Teams.Core/Http/BotHttpClient.cs new file mode 100644 index 000000000..f18474238 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Http/BotHttpClient.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core.Hosting; + +namespace Microsoft.Teams.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. +internal class BotHttpClient(HttpClient httpClient, ILogger? logger = null) +{ + private const string UserAgent = "teams.net/" + ThisAssembly.NuGetPackageVersion; + + 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. + 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?.HttpRequestSending(method, url, body); + + using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + logger?.HttpResponseReceived(method, url, (int)response.StatusCode); + + 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. + 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. + 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. + 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); + request.Headers.UserAgent.ParseAdd(UserAgent); + + 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?.ResourceNotFound(url); + return default; + } + + string errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + string responseHeaders = FormatResponseHeaders(response); + + logger?.HttpRequestError(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)) + { + // When T is string, try to deserialize as a JSON string first (unwraps quotes). + // Fall back to the raw response if it's not valid JSON. + // The (T)(object) cast is safe because we've verified T == string above. + Debug.Assert(typeof(T) == typeof(string), "Cast below assumes T is 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.Core/Http/BotRequestOptions.cs b/core/src/Microsoft.Teams.Core/Http/BotRequestOptions.cs new file mode 100644 index 000000000..4b90bd888 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Http/BotRequestOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Core.Http; + +using CustomHeaders = Dictionary; + +/// +/// Options for configuring a bot HTTP request. +/// +internal 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.Core/HttpRequestExtensions.cs b/core/src/Microsoft.Teams.Core/HttpRequestExtensions.cs new file mode 100644 index 000000000..528f959de --- /dev/null +++ b/core/src/Microsoft.Teams.Core/HttpRequestExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Teams.Core; + +/// +/// Extension methods for . +/// +public static class HttpRequestExtensions +{ + /// + /// Gets the Microsoft Correlation Vector (MS-CV) from the request headers, if present. + /// The value is sanitized to prevent log forging attacks by removing newline characters. + /// + public static string? GetCorrelationVector(this HttpRequest request) + { + if (request == null) + { + return string.Empty; + } + + string? correlationVector = request.Headers["MS-CV"].FirstOrDefault(); + + if (string.IsNullOrEmpty(correlationVector)) + { + return string.Empty; + } + + return correlationVector.Replace(Environment.NewLine, "", StringComparison.Ordinal); + } +} diff --git a/core/src/Microsoft.Teams.Core/ITurnMiddleWare.cs b/core/src/Microsoft.Teams.Core/ITurnMiddleWare.cs new file mode 100644 index 000000000..5f383097b --- /dev/null +++ b/core/src/Microsoft.Teams.Core/ITurnMiddleWare.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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. + /// + /// The bot application processing the current turn. + /// The incoming activity to process. + /// A delegate that invokes the next middleware in the pipeline. Call this to continue processing. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous middleware execution. + Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn nextTurn, CancellationToken cancellationToken = default); +} diff --git a/core/src/Microsoft.Teams.Core/Log.cs b/core/src/Microsoft.Teams.Core/Log.cs new file mode 100644 index 000000000..32996e25c --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Log.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Teams.Core; + +/// +/// High-performance logging methods generated via the source generator. +/// +internal static partial class Log +{ + // ── BotApplication ────────────────────────────────────────────────── + + private static readonly Func ActivityScopeCallback = + LoggerMessage.DefineScope("ActivityType={ActivityType} ActivityId={ActivityId} ServiceUrl={ServiceUrl} MSCV={MSCV}"); + + public static IDisposable? BeginActivityScope(this ILogger logger, string? activityType, string? activityId, Uri? serviceUrl, string? mscv) => + ActivityScopeCallback(logger, activityType, activityId, serviceUrl, mscv); + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}")] + public static partial void BotStarted(this ILogger logger, string thisType, string appId, string sdkVersion); + + [LoggerMessage(EventId = 2, Level = LogLevel.Debug, Message = "Start processing HTTP request for activity")] + public static partial void StartProcessingActivity(this ILogger logger); + + [LoggerMessage(EventId = 3, Level = LogLevel.Information, Message = "Activity received: Type={Type} Id={Id} ServiceUrl={ServiceUrl} MSCV={MSCV}")] + public static partial void ActivityReceived(this ILogger logger, string? type, string? id, Uri? serviceUrl, string? mscv); + + [LoggerMessage(EventId = 4, Level = LogLevel.Trace, Message = "Received activity: \n {Activity}")] + public static partial void ReceivedActivityJson(this ILogger logger, string activity); + + [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "Activity processing timed out after {Timeout}: Id={Id}")] + public static partial void ActivityTimedOut(this ILogger logger, TimeSpan timeout, string? id); + + [LoggerMessage(EventId = 6, Level = LogLevel.Error, Message = "Error processing activity: Id={Id}")] + public static partial void ActivityProcessingError(this ILogger logger, Exception ex, string? id); + + [LoggerMessage(EventId = 7, Level = LogLevel.Information, Message = "Finished processing activity: Id={Id}")] + public static partial void ActivityProcessingFinished(this ILogger logger, string? id); + + // ── ConversationClient ────────────────────────────────────────────── + + [LoggerMessage(EventId = 10, Level = LogLevel.Information, Message = "Truncating conversation ID for 'agents' channel to comply with length restrictions.")] + public static partial void TruncatingConversationId(this ILogger logger); + + [LoggerMessage(EventId = 11, Level = LogLevel.Trace, Message = "Updating activity at {Url}: {Activity}")] + public static partial void UpdatingActivity(this ILogger logger, string url, string activity); + + [LoggerMessage(EventId = 12, Level = LogLevel.Trace, Message = "Updating targeted activity at {Url}: {Activity}")] + public static partial void UpdatingTargetedActivity(this ILogger logger, string url, string activity); + + [LoggerMessage(EventId = 13, Level = LogLevel.Trace, Message = "Creating conversation at {Url} with parameters: {Parameters}")] + public static partial void CreatingConversation(this ILogger logger, string url, string parameters); + + [LoggerMessage(EventId = 14, Level = LogLevel.Trace, Message = "Sending conversation history to {Url}: {Transcript}")] + public static partial void SendingConversationHistory(this ILogger logger, string url, string transcript); + + [LoggerMessage(EventId = 15, Level = LogLevel.Trace, Message = "Uploading attachment to {Url}: {AttachmentData}")] + public static partial void UploadingAttachment(this ILogger logger, string url, string attachmentData); + + // ── BotHttpClient ─────────────────────────────────────────────────── + + [LoggerMessage(EventId = 20, Level = LogLevel.Trace, Message = "HTTP {Method} {Url} body: \n {Body}")] + public static partial void HttpRequestSending(this ILogger logger, HttpMethod method, string url, string? body); + + [LoggerMessage(EventId = 21, Level = LogLevel.Debug, Message = "HTTP {Method} {Url} Response Status {StatusCode}")] + public static partial void HttpResponseReceived(this ILogger logger, HttpMethod method, string url, int statusCode); + + [LoggerMessage(EventId = 22, Level = LogLevel.Warning, Message = "Resource not found: {Url}")] + public static partial void ResourceNotFound(this ILogger logger, string url); + + [LoggerMessage(EventId = 23, Level = LogLevel.Warning, Message = "HTTP request error {Method} {Url}\nStatus Code: {StatusCode}\nResponse Headers: {ResponseHeaders}\nResponse Body: {ResponseBody}")] + public static partial void HttpRequestError(this ILogger logger, HttpMethod method, string url, HttpStatusCode statusCode, string responseHeaders, string responseBody); + + // ── TurnMiddleware ────────────────────────────────────────────────── + + [LoggerMessage(EventId = 30, Level = LogLevel.Debug, Message = "Registered middleware '{Middleware}' (position {Position}).")] + public static partial void MiddlewareRegistered(this ILogger logger, string middleware, int position); + + [LoggerMessage(EventId = 31, Level = LogLevel.Debug, Message = "Middleware pipeline completed ({Count} middleware(s)).")] + public static partial void MiddlewarePipelineCompleted(this ILogger logger, int count); + + [LoggerMessage(EventId = 32, Level = LogLevel.Debug, Message = "Executing middleware '{Middleware}' ({Index}/{Count}).")] + public static partial void MiddlewareExecuting(this ILogger logger, string middleware, int index, int count); + + // ── BotAuthenticationHandler ──────────────────────────────────────── + + [LoggerMessage(EventId = 40, Level = LogLevel.Debug, Message = "Applying User-Assigned Managed Identity for token acquisition (ClientId '{ClientId}').")] + public static partial void ApplyingManagedIdentity(this ILogger logger, string clientId); + + // ── JwtExtensions ─────────────────────────────────────────────────── + + [LoggerMessage(EventId = 50, Level = LogLevel.Trace, Message = "Resolving signing keys from OIDC authority '{Authority}' for issuer '{Issuer}'.")] + public static partial void ResolvingSigningKeys(this ILogger logger, string authority, string issuer); + + [LoggerMessage(EventId = 51, Level = LogLevel.Trace, Message = "Token validated for scheme: {Scheme}")] + public static partial void TokenValidated(this ILogger logger, string scheme); + + [LoggerMessage(EventId = 52, Level = LogLevel.Trace, Message = "Incoming token claims:{Claims}")] + public static partial void IncomingTokenClaims(this ILogger logger, string claims); + + [LoggerMessage(EventId = 53, Level = LogLevel.Warning, Message = "Forbidden for scheme: {Scheme}")] + public static partial void ForbiddenForScheme(this ILogger logger, string scheme); + + [LoggerMessage(EventId = 54, Level = LogLevel.Error, Message = "JWT authentication failed for scheme {Scheme}: {ExceptionMessage} | token iss={TokenIssuer} aud={TokenAudience} exp={TokenExpiration} sub={TokenSubject} | expected aud={ConfiguredAudience}")] + public static partial void JwtAuthenticationFailed(this ILogger logger, Exception ex, string scheme, string exceptionMessage, string tokenIssuer, string tokenAudience, string tokenExpiration, string tokenSubject, string configuredAudience); + + [LoggerMessage(EventId = 55, Level = LogLevel.Warning, Message = "ClientId not provided for scheme '{SchemeName}'. Configuring bypass authentication (no token validation). This is INSECURE and should only be used for development.")] + public static partial void BypassAuthenticationConfigured(this ILogger logger, string schemeName); + + [LoggerMessage(EventId = 56, Level = LogLevel.Warning, Message = "Using bypass authentication scheme succeeded for scheme: {Scheme}. This is INSECURE and should only be used for development.")] + public static partial void BypassAuthenticationSucceeded(this ILogger logger, string scheme); +} diff --git a/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj b/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj new file mode 100644 index 000000000..fa0fe5c17 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net10.0 + enable + enable + True + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/Microsoft.Teams.Core/README.md b/core/src/Microsoft.Teams.Core/README.md new file mode 100644 index 000000000..d8cbe1db3 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/README.md @@ -0,0 +1,162 @@ + + + +# Microsoft.Teams.Core + +The foundational .NET library for building Microsoft Teams bots. It implements the [Activity Protocol](https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md), providing the core bot application framework, conversation client, user token client, middleware pipeline, and support for both Bot and Agentic identities. + +## Key Features + +- **Activity Processing** — Receive, deserialize, and dispatch activities through a middleware pipeline +- **Conversation Client** — Send, update, and delete activities; manage conversation members and metadata +- **User Token Client** — OAuth token management, sign-in flows, and token exchange (SSO) +- **Middleware Pipeline** — Extensible `ITurnMiddleware` chain for cross-cutting concerns +- **Flexible Authentication** — Client secrets, managed identities (system/user-assigned), federated identities, and agentic (user-delegated) tokens via MSAL +- **Extensible Schema** — Loose `CoreActivity` model with `JsonExtensionData` for channel-specific properties +- **AOT-Compatible** — Source-generated JSON serialization via `CoreActivityJsonContext` + +## Installation + +```shell +dotnet add package Microsoft.Teams.Core +``` + +## Quick Start + +### Register Services & Map Endpoint + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.AddBotApplication(); + +var app = builder.Build(); +var bot = app.UseBotApplication(); // maps POST /api/messages + +bot.OnActivity = async (activity, ct) => +{ + if (activity.Type == ActivityType.Message) + { + var reply = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithConversation(activity.Conversation) + .WithServiceUrl(activity.ServiceUrl) + .WithProperty("text", "Hello from the bot!") + .Build(); + + await bot.SendActivityAsync(reply, ct); + } +}; + +app.Run(); +``` + +### Custom Bot Subclass + +```csharp +public class MyBot : BotApplication +{ + public MyBot( + ConversationClient conversationClient, + UserTokenClient tokenClient, + ILogger logger) + : base(conversationClient, tokenClient, logger) + { + OnActivity = HandleActivityAsync; + } + + private async Task HandleActivityAsync(CoreActivity activity, CancellationToken ct) + { + // your logic here + } +} + +// Registration +builder.AddBotApplication(); +var bot = app.UseBotApplication(); +``` + +### Middleware + +```csharp +public class LoggingMiddleware : ITurnMiddleware +{ + public async Task OnTurnAsync( + BotApplication bot, CoreActivity activity, NextTurn next, CancellationToken ct) + { + Console.WriteLine($"Activity: {activity.Type} from {activity.From?.Name}"); + await next(ct); + } +} + +bot.UseMiddleware(new LoggingMiddleware()); +``` + +### Extensible Activity Schema + +```csharp +public class MyChannelData : ChannelData +{ + [JsonPropertyName("customField")] + public string? CustomField { get; set; } +} + +public class MyActivity : CoreActivity +{ + [JsonPropertyName("channelData")] + public new MyChannelData? ChannelData { get; set; } +} +``` + +## Configuration + +Provide credentials via `appsettings.json`: + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "", + "ClientId": "", + "Scope": "https://api.botframework.com/.default", + "ClientCredentials": [ + { + "SourceType": "ClientSecret", + "ClientSecret": "" + } + ] + } +} +``` + +Or via environment variables: + +```env +AzureAd__TenantId= +AzureAd__ClientId= +AzureAd__ClientCredentials__0__SourceType=ClientSecret +AzureAd__ClientCredentials__0__ClientSecret= +``` + +When no MSAL configuration is provided, communication happens as anonymous REST calls, suitable for localhost testing. + +## Design Principles + +- **Loose schema** — `CoreActivity` contains only strictly required fields; additional fields are captured via `JsonExtensionData` +- **Simple serialization** — No custom converters; standard `System.Text.Json` with source generation +- **Extensible schema** — `ChannelData` and `ConversationAccount` support extension properties; generics allow custom types +- **MSAL-based auth** — Token acquisition built on top of Microsoft Identity Web +- **ASP.NET DI** — All dependencies configured via `IServiceCollection` extensions, reusing the existing `HttpClient` factory +- **ILogger & IConfiguration** — Standard .NET logging and configuration throughout + +## Main Types + +| Type | Description | +|------|-------------| +| `BotApplication` | Core entry point — processes HTTP requests, runs middleware, dispatches to handlers | +| `ConversationClient` | HTTP client for Bot Framework conversation APIs (send, update, delete, members) | +| `UserTokenClient` | HTTP client for Bot Framework Token Service (OAuth, SSO, sign-in) | +| `CoreActivity` | Activity data model following the Activity Protocol specification | +| `CoreActivityBuilder` | Fluent builder for constructing `CoreActivity` instances | +| `ITurnMiddleware` | Interface for middleware in the activity processing pipeline | +| `AgenticIdentity` | Represents user-delegated token acquisition identity | +| `BotHandlerException` | Exception wrapper preserving the activity that caused the error | diff --git a/core/src/Microsoft.Teams.Core/Schema/ActivityType.cs b/core/src/Microsoft.Teams.Core/Schema/ActivityType.cs new file mode 100644 index 000000000..1e51e19be --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Schema/ActivityType.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.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 a message activity type, used for sending and receiving text and rich content. + /// + public const string Message = "message"; + /// + /// Represents a typing indicator activity. + /// + public const string Typing = "typing"; +} diff --git a/core/src/Microsoft.Teams.Core/Schema/AgenticIdentity.cs b/core/src/Microsoft.Teams.Core/Schema/AgenticIdentity.cs new file mode 100644 index 000000000..0afd913d1 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Schema/AgenticIdentity.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.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 from a 's typed agentic fields. + /// Returns null if the account is null or has no agentic fields set. + /// + public static AgenticIdentity? FromAccount(ConversationAccount? account) + { + if (account is null || (account.AgenticAppId is null && account.AgenticUserId is null && account.AgenticAppBlueprintId is null)) + { + return null; + } + + return new AgenticIdentity + { + AgenticAppId = account.AgenticAppId, + AgenticUserId = account.AgenticUserId, + AgenticAppBlueprintId = account.AgenticAppBlueprintId + }; + } +} diff --git a/core/src/Microsoft.Teams.Core/Schema/ChannelData.cs b/core/src/Microsoft.Teams.Core/Schema/ChannelData.cs new file mode 100644 index 000000000..aacf4ca66 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Schema/ChannelData.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.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] + public ExtendedPropertiesDictionary Properties { get; set; } = []; +} diff --git a/core/src/Microsoft.Teams.Core/Schema/Conversation.cs b/core/src/Microsoft.Teams.Core/Schema/Conversation.cs new file mode 100644 index 000000000..0f9b61a2b --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Schema/Conversation.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Core.Schema; + +/// +/// Represents a conversation, including its unique identifier and associated extended properties. +/// +/// +/// Initializes a new instance of the class. +/// +public class Conversation(string id = "") +{ + /// + /// Gets or sets the unique identifier for the object. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = id; + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] + public ExtendedPropertiesDictionary Properties { get; set; } = []; +} diff --git a/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs b/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs new file mode 100644 index 000000000..c0758112c --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.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 or sets a value indicating whether this is a targeted message visible only to this recipient. + /// + [JsonPropertyName("isTargeted")] + public bool? IsTargeted { get; set; } + + /// + /// Gets or sets the agentic application ID for user-delegated token acquisition. + /// + [JsonPropertyName("agenticAppId")] + public string? AgenticAppId { get; set; } + + /// + /// Gets or sets the agentic user ID for user-delegated token acquisition. + /// + [JsonPropertyName("agenticUserId")] + public string? AgenticUserId { get; set; } + + /// + /// Gets or sets the agentic application blueprint ID. + /// + [JsonPropertyName("agenticAppBlueprintId")] + public string? AgenticAppBlueprintId { get; set; } + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] + public ExtendedPropertiesDictionary Properties { get; set; } = []; + + /// + /// Gets the agentic identity from the account's typed properties. + /// + /// An AgenticIdentity instance if agentic identity information is present; otherwise, null. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate")] + public AgenticIdentity? GetAgenticIdentity() + { + if (AgenticAppId is null && AgenticUserId is null && AgenticAppBlueprintId is null) + { + return null; + } + + return new AgenticIdentity + { + AgenticAppId = AgenticAppId, + AgenticUserId = AgenticUserId, + AgenticAppBlueprintId = AgenticAppBlueprintId + }; + } +} diff --git a/core/src/Microsoft.Teams.Core/Schema/CoreActivity.cs b/core/src/Microsoft.Teams.Core/Schema/CoreActivity.cs new file mode 100644 index 000000000..ed9b06a79 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Schema/CoreActivity.cs @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Teams.Core.Schema; + +/// +/// Represents a dictionary for storing extended properties as key-value pairs. +/// +public class ExtendedPropertiesDictionary : Dictionary +{ + /// + /// Initializes a new empty instance of the class. + /// + public ExtendedPropertiesDictionary() { } + + /// + /// Initializes a new instance of the class by shallow-copying entries from another dictionary. + /// + public ExtendedPropertiesDictionary(IDictionary source) : base(source) { } + + /// + /// Extracts and deserializes a value from the dictionary, removing the entry if found. + /// Returns the deserialized value, or default if the key is not present. + /// + public T? Extract(string key) + { + if (!TryGetValue(key, out object? raw)) + return default; + + Remove(key); + + if (raw is T typed) + return typed; + + if (raw is System.Text.Json.JsonElement element) + return System.Text.Json.JsonSerializer.Deserialize(element.GetRawText()); + + return default; + } + + /// + /// Gets and deserializes a value from the dictionary without removing it. + /// Handles values that result from deserialization. + /// + public T? Get(string key) + { + if (!TryGetValue(key, out object? raw)) + return default; + + if (raw is T typed) + return typed; + + if (raw is System.Text.Json.JsonElement element) + { + T? deserialized = System.Text.Json.JsonSerializer.Deserialize(element.GetRawText()); + this[key] = deserialized; + return deserialized; + } + + return default; + } +} + +/// +/// 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 the identifier of the activity this activity is a reply to. + /// + [JsonPropertyName("replyToId")] public string? ReplyToId { get; set; } + + /// + /// Gets or sets the conversation information for this activity. + /// + [JsonPropertyName("conversation")] public Conversation? Conversation { get; set; } + + /// + /// Gets or sets the sender account for this activity. + /// + [JsonPropertyName("from")] public ConversationAccount? From { get; set; } + + /// + /// Gets or sets the recipient account for this activity. + /// + [JsonPropertyName("recipient")] public ConversationAccount? Recipient { get; set; } + + /// + /// Gets the extension data dictionary for storing additional properties not defined in the schema. + /// + [JsonExtensionData] public ExtendedPropertiesDictionary Properties { get; set; } = []; + + /// + /// 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. + /// Defaults to . + /// + /// The activity type. Defaults to "message". + [JsonConstructor] + internal CoreActivity(string type = ActivityType.Message) + { + Type = type; + } + + /// + /// 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; + Conversation = activity.Conversation is not null ? new Conversation(activity.Conversation.Id) { Properties = new ExtendedPropertiesDictionary(activity.Conversation.Properties) } : null; + From = activity.From is not null ? CloneConversationAccount(activity.From) : null; + Recipient = activity.Recipient is not null ? CloneConversationAccount(activity.Recipient) : null; + Properties = new ExtendedPropertiesDictionary(activity.Properties); + } + + private static ConversationAccount CloneConversationAccount(ConversationAccount source) => new() + { + Id = source.Id, + Name = source.Name, + IsTargeted = source.IsTargeted, + AgenticAppId = source.AgenticAppId, + AgenticUserId = source.AgenticUserId, + AgenticAppBlueprintId = source.AgenticAppBlueprintId, + Properties = new ExtendedPropertiesDictionary(source.Properties) + }; + + /// + /// Serializes the current activity to a JSON string. + /// + /// A JSON string representation of the activity. + public virtual string ToJson() + => JsonSerializer.Serialize(this, CoreActivityJsonContext.Default.CoreActivity); + + /// + /// Serializes the current activity to a JSON string using the specified for source-generated serialization. + /// + /// The type of the activity to serialize. Must inherit from . + /// The JSON type info that provides serialization metadata for type . + /// A JSON string representation of the activity. + 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); + + /// + /// Asynchronously deserializes a JSON stream into an instance of type using the specified for source-generated serialization. + /// + /// The type of the activity to deserialize. Must inherit from . + /// The stream containing JSON data to deserialize. + /// The JSON type info that provides deserialization metadata for type . + /// A cancellation token to cancel the operation. + /// A representing the asynchronous operation. The result contains the deserialized activity, or null if deserialization fails. + 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. + /// + /// A new instance. + public static CoreActivityBuilder CreateBuilder() => new(); + +} diff --git a/core/src/Microsoft.Teams.Core/Schema/CoreActivityBuilder.cs b/core/src/Microsoft.Teams.Core/Schema/CoreActivityBuilder.cs new file mode 100644 index 000000000..d168c9ea4 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Schema/CoreActivityBuilder.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.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; + } + + /// + /// 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 service URL from a string. + /// + /// The service URL as a string. + /// The builder instance for chaining. + public TBuilder WithServiceUrl(string serviceUrlString) + { + _activity.ServiceUrl = new Uri(serviceUrlString); + 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; + } + + /// + /// Sets the conversation information. + /// + /// The conversation information. + /// The builder instance for chaining. + public TBuilder WithConversation(Conversation conversation) + { + _activity.Conversation = conversation; + return (TBuilder)this; + } + + /// + /// Sets the sender account information. + /// + /// The sender account. + /// The builder instance for chaining. + public TBuilder WithFrom(ConversationAccount? from) + { + _activity.From = from; + return (TBuilder)this; + } + + /// + /// Sets the recipient account information. + /// + /// The recipient account. + /// The builder instance for chaining. + public TBuilder WithRecipient(ConversationAccount? recipient) + { + _activity.Recipient = recipient; + 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; + } + + /// + /// 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) + { + } + + /// + /// Builds and returns the configured CoreActivity instance. + /// + /// The configured CoreActivity. + public override CoreActivity Build() + { + return _activity; + } +} diff --git a/core/src/Microsoft.Teams.Core/Schema/CoreActivityJsonContext.cs b/core/src/Microsoft.Teams.Core/Schema/CoreActivityJsonContext.cs new file mode 100644 index 000000000..6a11b5104 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Schema/CoreActivityJsonContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.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.Text.Json.Nodes.JsonNode))] +[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.Core/TurnMiddleware.cs b/core/src/Microsoft.Teams.Core/TurnMiddleware.cs new file mode 100644 index 000000000..807d30a31 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/TurnMiddleware.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Core; + +/// +/// Manages and executes a middleware pipeline for processing bot turns. +/// +/// +/// This class implements a chain of responsibility pattern where each middleware component can process +/// an activity before passing control to the next middleware in the pipeline. The pipeline executes +/// sequentially, with each middleware having the opportunity to modify the activity, perform side effects, +/// or short-circuit the pipeline. Middleware is executed in the order it was registered via the Use method. +/// +internal sealed class TurnMiddleware : ITurnMiddleware, IEnumerable +{ + private readonly IList _middlewares = []; + private ILogger _logger = NullLogger.Instance; + + internal void SetLogger(ILogger logger) => _logger = logger; + + /// + /// Adds a middleware component to the end of the pipeline. + /// + /// The middleware to add. Cannot be null. + /// The current TurnMiddleware instance for method chaining. + internal TurnMiddleware Use(ITurnMiddleware middleware) + { + _middlewares.Add(middleware); + _logger.MiddlewareRegistered(middleware.GetType().Name, _middlewares.Count); + return this; + } + + /// + /// Processes a turn by executing the middleware pipeline. + /// + /// The bot application processing the turn. + /// The activity to process. + /// Delegate to invoke the next middleware in the outer pipeline. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous pipeline execution. + 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); + } + + /// + /// Recursively executes the middleware pipeline starting from the specified index. + /// + /// The bot application processing the turn. + /// The activity to process. + /// Optional callback to invoke after all middleware has executed. + /// The index of the next middleware to execute in the pipeline. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous pipeline execution. + public Task RunPipelineAsync(BotApplication botApplication, CoreActivity activity, Func? callback, int nextMiddlewareIndex, CancellationToken cancellationToken) + { + if (nextMiddlewareIndex == _middlewares.Count) + { + if (nextMiddlewareIndex > 0) + { + _logger.MiddlewarePipelineCompleted(nextMiddlewareIndex); + } + return callback is not null ? callback!(activity, cancellationToken) ?? Task.CompletedTask : Task.CompletedTask; + } + ITurnMiddleware nextMiddleware = _middlewares[nextMiddlewareIndex]; + _logger.MiddlewareExecuting(nextMiddleware.GetType().Name, nextMiddlewareIndex + 1, _middlewares.Count); + 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.Core/UserTokenClient.Models.cs b/core/src/Microsoft.Teams.Core/UserTokenClient.Models.cs new file mode 100644 index 000000000..d59a4bcac --- /dev/null +++ b/core/src/Microsoft.Teams.Core/UserTokenClient.Models.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.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.Core/UserTokenClient.cs b/core/src/Microsoft.Teams.Core/UserTokenClient.cs new file mode 100644 index 000000000..078afc7b7 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/UserTokenClient.cs @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core.Http; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Core; + +/// +/// Client for managing user tokens via HTTP requests to the Bot Framework Token Service. +/// +/// +/// This client provides methods for OAuth token management including retrieving tokens, exchanging tokens, +/// signing out users, and managing AAD tokens. The client communicates with the Bot Framework Token Service +/// API endpoint (defaults to https://token.botframework.com but can be configured via UserTokenApiEndpoint). +/// +/// The HTTP client for making requests to the token service. +/// Configuration containing the UserTokenApiEndpoint setting and other bot configuration. +/// Logger for diagnostic information and request tracking. +public class UserTokenClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) +{ + internal const string UserTokenHttpClientName = "BotUserTokenClient"; + 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. + /// A task that represents the asynchronous operation. The result contains an array of token status results for each connection. + public virtual 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); + } + 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. + /// A task that represents the asynchronous operation. The result contains the token, or null if no token is available. + public virtual 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); + } + + 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. + /// Builds the state parameter internally from the userId and connectionName. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The optional final redirect URL. + /// The cancellation token. + /// A task that represents the asynchronous operation. The result contains the sign-in resource with the sign-in link and token exchange information. + public virtual 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)); + + Uri? finalRedirectUri = finalRedirect is not null ? new Uri(finalRedirect) : null; + return GetSignInResourceAsync(state, finalRedirect: finalRedirectUri, cancellationToken: cancellationToken); + } + + /// + /// Gets the sign-in URL for the given state. + /// + /// The encoded state parameter. + /// The optional code challenge for PKCE. + /// The optional emulator URL. + /// The optional final redirect URL. + /// The cancellation token. + /// The sign-in URL, or null if not available. + public virtual async Task GetSignInUrlAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() { { "state", state } }; + + if (!string.IsNullOrEmpty(codeChallenge)) + queryParams.Add("code_challenge", codeChallenge); + if (emulatorUrl is not null) + queryParams.Add("emulatorUrl", emulatorUrl.ToString()); + if (finalRedirect is not null) + queryParams.Add("finalRedirect", finalRedirect.ToString()); + + return await _botHttpClient.SendAsync( + HttpMethod.Get, + _apiEndpoint, + "api/botsignin/GetSignInUrl", + queryParams, + body: null, + CreateRequestOptions("getting sign-in URL"), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the sign-in resource for the given state. + /// + /// The encoded state parameter. + /// The optional code challenge for PKCE. + /// The optional emulator URL. + /// The optional final redirect URL. + /// The cancellation token. + /// The sign-in resource result. + public virtual async Task GetSignInResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default) + { + Dictionary queryParams = new() { { "state", state } }; + + if (!string.IsNullOrEmpty(codeChallenge)) + queryParams.Add("code_challenge", codeChallenge); + if (emulatorUrl is not null) + queryParams.Add("emulatorUrl", emulatorUrl.ToString()); + if (finalRedirect is not null) + queryParams.Add("finalRedirect", finalRedirect.ToString()); + + 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 virtual 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 + }; + + 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, revoking their OAuth token. + /// + /// The unique identifier of the user to sign out. Cannot be null or empty. + /// Optional name of the OAuth connection to sign out from. If null, signs out from all connections. + /// Optional channel identifier. If provided, limits sign-out to tokens for this channel. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous sign-out operation. + public virtual 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); + } + + 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. + /// A task that represents the asynchronous operation. The result contains a dictionary mapping resource URLs to their token results. + public virtual async Task> GetAadTokensAsync(string userId, string connectionName, string channelId, string[]? resourceUrls = null, CancellationToken cancellationToken = default) + { + var body = new + { + channelId, + connectionName, + userId, + resourceUrls = resourceUrls ?? [] + }; + + 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 000000000..fa33988f0 --- /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 000000000..238dc87ae --- /dev/null +++ b/core/test/ABSTokenServiceClient/Program.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using ABSTokenServiceClient; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Core.Hosting; + +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 000000000..fdef6fee0 --- /dev/null +++ b/core/test/ABSTokenServiceClient/UserTokenCLIService.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core; + +namespace ABSTokenServiceClient +{ + 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) + { + string userId = Environment.GetEnvironmentVariable("TEST_USER_ID") ?? throw new ArgumentNullException("TEST_USER_ID not found"); + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") ?? throw new ArgumentNullException("TEST Connection name nor found"); + const string channelId = "msteams"; + + logger.LogInformation("Application started"); + + 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 000000000..3c9252dc5 --- /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 000000000..05ab0eb9d --- /dev/null +++ b/core/test/IntegrationTests.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/core/test/IntegrationTests/ApiClientTests.cs b/core/test/IntegrationTests/ApiClientTests.cs new file mode 100644 index 000000000..f1fec30a0 --- /dev/null +++ b/core/test/IntegrationTests/ApiClientTests.cs @@ -0,0 +1,458 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.Handlers.MessageExtension; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Integration tests for sub-clients making real API calls. +/// These tests verify that the ApiClient facade correctly delegates to core ConversationClient +/// and that Teams/Meeting-specific BotHttpClient calls work end-to-end. +/// +public class ApiClientTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + private readonly ApiClient _api; + + public ApiClientTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _f.OutputHelper = output; + _output = output; + _api = _f.ScopedApiClient; + } + + private static CoreActivity CreateMessageActivity(string text) => + CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithProperty("text", text) + .Build(); + + private static CoreActivity CreateMessageActivity(string text, ConversationAccount recipient) => + CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithRecipient(recipient) + .WithProperty("text", text) + .Build(); + + #region Activities + + [Fact(Timeout = 5000)] + public async Task Activities_CreateAsync() + { + CoreActivity activity = CreateMessageActivity($"[ApiClient.Activities.Create] at `{DateTime.UtcNow:s}`"); + + SendActivityResponse? res = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + + Assert.NotNull(res); + Assert.NotNull(res.Id); + _output.WriteLine($"Created activity: {res.Id}"); + } + + [Fact(Timeout = 5000)] + public async Task Activities_UpdateAsync() + { + CoreActivity original = CreateMessageActivity($"[ApiClient.Activities.Update] Original at `{DateTime.UtcNow:s}`"); + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, original); + Assert.NotNull(sent?.Id); + + CoreActivity updated = CreateMessageActivity($"[ApiClient.Activities.Update] Updated at `{DateTime.UtcNow:s}`"); + + UpdateActivityResponse? res = await _api.Conversations.Activities.UpdateAsync( + _f.ConversationId, sent.Id, updated); + + Assert.NotNull(res?.Id); + _output.WriteLine($"Updated activity: {res.Id}"); + } + + [Fact(Timeout = 5000)] + public async Task Activities_ReplyAsync() + { + CoreActivity original = CreateMessageActivity($"[ApiClient.Activities.Reply] Parent at `{DateTime.UtcNow:s}`"); + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, original); + Assert.NotNull(sent?.Id); + + CoreActivity reply = CreateMessageActivity($"[ApiClient.Activities.Reply] Reply at `{DateTime.UtcNow:s}`"); + + SendActivityResponse? res = await _api.Conversations.Activities.ReplyAsync( + _f.ConversationId, sent.Id, reply); + + Assert.NotNull(res); + _output.WriteLine($"Reply activity: {res?.Id}"); + } + + [Fact(Timeout = 5000)] + public async Task Activities_DeleteAsync() + { + CoreActivity activity = CreateMessageActivity($"[ApiClient.Activities.Delete] at `{DateTime.UtcNow:s}`"); + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + Assert.NotNull(sent?.Id); + + await Task.Delay(2000); + + await _api.Conversations.Activities.DeleteAsync(_f.ConversationId, sent.Id, _f.AgenticIdentity); + _output.WriteLine($"Deleted activity: {sent.Id}"); + } + + #endregion + + #region Targeted Activities + + [Fact] + public async Task Activities_CreateTargetedAsync() + { + // Targeted activities require a valid Recipient — get a real member ID + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + + CoreActivity activity = CreateMessageActivity( + $"[ApiClient.Activities.CreateTargeted] at `{DateTime.UtcNow:s}`", + new ConversationAccount { Id = members[0].Id }); + + SendActivityResponse? res = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, activity); + + Assert.NotNull(res); + Assert.NotNull(res.Id); + _output.WriteLine($"Created targeted activity: {res.Id}"); + } + + [Fact] + public async Task Activities_UpdateTargetedAsync() + { + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + + CoreActivity original = CreateMessageActivity( + $"[ApiClient.Activities.UpdateTargeted] Original at `{DateTime.UtcNow:s}`", + new ConversationAccount { Id = members[0].Id }); + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, original); + Assert.NotNull(sent?.Id); + + CoreActivity updated = CreateMessageActivity($"[ApiClient.Activities.UpdateTargeted] Updated at `{DateTime.UtcNow:s}`"); + + UpdateActivityResponse? res = await _api.Conversations.Activities.UpdateTargetedAsync( + _f.ConversationId, sent.Id, updated); + + Assert.NotNull(res?.Id); + _output.WriteLine($"Updated targeted activity: {res.Id}"); + } + + [Fact] + public async Task Activities_DeleteTargetedAsync() + { + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + + CoreActivity activity = CreateMessageActivity( + $"[ApiClient.Activities.DeleteTargeted] at `{DateTime.UtcNow:s}`", + new ConversationAccount { Id = members[0].Id }); + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateTargetedAsync(_f.ConversationId, activity); + Assert.NotNull(sent?.Id); + + await Task.Delay(2000); + + await _api.Conversations.Activities.DeleteTargetedAsync(_f.ConversationId, sent.Id, _f.AgenticIdentity); + _output.WriteLine($"Deleted targeted activity: {sent.Id}"); + } + + #endregion + + #region Members + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task Members_GetAsync() + { + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + foreach (ConversationAccount m in members) + { + _output.WriteLine($"Member: {m.Id} — {m.Name}"); + } + } + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task Members_GetByIdAsync() + { + // Get MRI-format member ID from the members list first + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + ConversationAccount member = await _api.Conversations.Members.GetByIdAsync( + _f.ConversationId, memberId, _f.AgenticIdentity); + + Assert.NotNull(member); + Assert.Equal(memberId, member.Id); + _output.WriteLine($"Member: {member.Id} — {member.Name}"); + } + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task Members_GetByIdAsync_AsTeamsConversationAccount() + { + // Get MRI-format member ID from the members list first + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + TeamsConversationAccount member = await _api.Conversations.Members.GetByIdAsync( + _f.ConversationId, memberId, _f.AgenticIdentity); + + Assert.NotNull(member); + Assert.Equal(memberId, member.Id); + _output.WriteLine($"Member: {member.Id} — {member.Name}, Email: {member.Email}, UPN: {member.UserPrincipalName}"); + } + + #endregion + + #region Reactions + + [Fact] + public async Task Reactions_AddAndDelete() + { + CoreActivity activity = CreateMessageActivity($"[ApiClient.Reactions] Test at `{DateTime.UtcNow:s}`"); + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + Assert.NotNull(sent?.Id); + + await _api.Conversations.Reactions.AddAsync(_f.ConversationId, sent.Id, "like", _f.AgenticIdentity); + _output.WriteLine("Added 'like' reaction"); + + await Task.Delay(1000); + + await _api.Conversations.Reactions.DeleteAsync(_f.ConversationId, sent.Id, "like", _f.AgenticIdentity); + _output.WriteLine("Removed 'like' reaction"); + } + + #endregion + + #region Teams + + [Fact(Timeout = 5000)] + public async Task Teams_GetByIdAsync() + { + Team? team = await _api.Teams.GetByIdAsync(_f.TeamId, _f.AgenticIdentity); + + Assert.NotNull(team); + _output.WriteLine($"Team: {team.Id} — {team.Name}, Members: {team.MemberCount}, Channels: {team.ChannelCount}"); + } + + [Fact(Timeout = 5000)] + public async Task Teams_GetConversationsAsync() + { + List? channels = await _api.Teams.GetConversationsAsync(_f.TeamId, _f.AgenticIdentity); + + Assert.NotNull(channels); + Assert.NotEmpty(channels); + + foreach (TeamsChannel ch in channels) + { + _output.WriteLine($"Channel: {ch.Id} — {ch.Name}"); + } + } + + #endregion + + #region Meetings + + [Fact(Timeout = 5000)] + public async Task Meetings_GetByIdAsync() + { + Meeting? meeting = await _api.Meetings.GetByIdAsync(_f.MeetingId, _f.AgenticIdentity); + + Assert.NotNull(meeting); + _output.WriteLine($"Meeting: {meeting.Id}"); + if (meeting.Details is not null) + { + _output.WriteLine($" Title: {meeting.Details.Title}, Type: {meeting.Details.Type}"); + } + } + + [Fact(Timeout = 5000)] + public async Task Meetings_GetParticipantAsync() + { + // The meetings participant API requires AAD object ID, not MRI/pairwise bot framework ID. + // Get the AAD object ID from a human member (bots don't have one). + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + + string? aadObjectId = null; + foreach (ConversationAccount m in members) + { + TeamsConversationAccount tm = await _api.Conversations.Members + .GetByIdAsync(_f.ConversationId, m.Id!, _f.AgenticIdentity); + _output.WriteLine($"Member: {tm.Name} — AadObjectId: {tm.AadObjectId ?? "(null)"}, Properties: [{string.Join(", ", tm.Properties.Keys)}]"); + if (tm.AadObjectId is not null) + { + aadObjectId = tm.AadObjectId; + break; + } + } + + if (aadObjectId is null) + { + _output.WriteLine("SKIP: No members with AAD object ID found in test conversation"); + return; + } + + MeetingParticipant? participant = await _api.Meetings.GetParticipantAsync( + _f.MeetingId, aadObjectId, _f.TenantId, _f.AgenticIdentity); + + Assert.NotNull(participant); + _output.WriteLine($"Participant: {participant.User?.Id} — Role: {participant.Meeting?.Role}, InMeeting: {participant.Meeting?.InMeeting}"); + } + + #endregion + + #region Bots — SignIn + + [SkippableFact(Timeout = 5000)] + public async Task Bots_SignIn_GetUrlAsync() + { + Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); + + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") + ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); + + var tokenExchangeState = new + { + ConnectionName = connectionName, + Conversation = new + { + User = new ConversationAccount { Id = _f.UserId }, + } + }; + string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState); + string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); + + string? url = await _api.Bots.SignIn.GetUrlAsync(state); + + Assert.NotNull(url); + Assert.StartsWith("https://", url); + _output.WriteLine($"SignIn URL: {url}"); + } + + [SkippableFact(Timeout = 5000)] + public async Task Bots_SignIn_GetResourceAsync() + { + Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); + + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") + ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); + + var tokenExchangeState = new + { + ConnectionName = connectionName, + Conversation = new + { + User = new ConversationAccount { Id = _f.UserId }, + } + }; + string tokenExchangeStateJson = JsonSerializer.Serialize(tokenExchangeState); + string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson)); + + + var resource = await _api.Bots.SignIn.GetResourceAsync(state); + + Assert.NotNull(resource); + _output.WriteLine($"SignIn Resource: {resource.SignInLink}"); + } + + #endregion + + #region Users — Token + + [SkippableFact(Timeout = 5000)] + public async Task Users_Token_GetStatusAsync() + { + Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); + + // Get a valid member ID from the conversation + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + string userId = members[0].Id!; + + IList? statuses = await _api.Users.Token.GetStatusAsync(userId, "msteams"); + + // May return null or empty if user has no token connections — that's OK + _output.WriteLine($"Token statuses: {statuses?.Count ?? 0} connections"); + if (statuses is not null) + { + foreach (var s in statuses) + { + _output.WriteLine($" Connection: {s.ConnectionName}, HasToken: {s.HasToken}"); + } + } + } + + [SkippableFact(Timeout = 5000)] + public async Task Users_Token_GetAsync() + { + Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); + + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") + ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); + + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + + var result = await _api.Users.Token.GetAsync(members[0].Id!, connectionName, "msteams"); + _output.WriteLine($"Token: {(result is not null ? "acquired" : "not available")}"); + } + + [SkippableFact(Timeout = 5000)] + public async Task Users_Token_SignOutAsync() + { + Skip.If(_f.AgenticIdentity is not null, "UserTokenClient does not support agentic identity"); + + string connectionName = Environment.GetEnvironmentVariable("TEST_CONNECTION_NAME") + ?? throw new InvalidOperationException("TEST_CONNECTION_NAME not set"); + + IList members = await _api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + + await _api.Users.Token.SignOutAsync(members[0].Id!, connectionName, "msteams"); + _output.WriteLine("SignOut completed"); + } + + #endregion + + #region ForServiceUrl + + [Fact(Timeout = 5000)] + public async Task ForServiceUrl_CreatesScopedClient() + { + ApiClient scoped = _f.ApiClient.ForServiceUrl(_f.ServiceUrl); + + Assert.NotNull(scoped.Conversations); + Assert.NotNull(scoped.Teams); + Assert.NotNull(scoped.Meetings); + Assert.Equal(_f.ServiceUrl, scoped.ServiceUrl); + + // Verify the scoped client can make a real call + IList members = await scoped.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotNull(members); + Assert.NotEmpty(members); + _output.WriteLine($"ForServiceUrl scoped client retrieved {members.Count} members"); + } + + #endregion +} diff --git a/core/test/IntegrationTests/CompatTeamsInfoTests.cs b/core/test/IntegrationTests/CompatTeamsInfoTests.cs new file mode 100644 index 000000000..cc9ab6f6a --- /dev/null +++ b/core/test/IntegrationTests/CompatTeamsInfoTests.cs @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Builder; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.Teams; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.BotBuilder; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Xunit.Abstractions; +using CoreConversationAccount = Microsoft.Teams.Core.Schema.ConversationAccount; + +namespace IntegrationTests; + +/// +/// Integration tests for static methods making real API calls. +/// These tests verify that TeamsApiClient correctly bridges Bot Framework ITurnContext +/// to the underlying ConversationClient and ApiClient, producing valid compat types. +/// +public class TeamsApiClientTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + + public TeamsApiClientTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _f.OutputHelper = output; + _output = output; + } + + private ChannelAccount CreateFromAccount() + { + ChannelAccount from = new() { Id = "bot" }; + if (_f.AgenticIdentity is not null) + { + from.Properties.Add("agenticAppId", _f.AgenticIdentity.AgenticAppId); + from.Properties.Add("agenticUserId", _f.AgenticIdentity.AgenticUserId); + from.Properties.Add("agenticAppBlueprintId", _f.AgenticIdentity.AgenticAppBlueprintId); + } + + return from; + } + + /// + /// Creates an ITurnContext wired to real clients, simulating what TeamsBotFrameworkHttpAdapter does. + /// + private TurnContext CreateTurnContext( + string? conversationId = null, + string? teamId = null, + string? meetingId = null, + string? tenantId = null) + { + Activity activity = new() + { + Type = ActivityTypes.Message, + ServiceUrl = _f.ServiceUrl.ToString(), + ChannelId = "msteams", + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = conversationId ?? _f.ConversationId }, + From = CreateFromAccount(), + Recipient = new ChannelAccount { Id = "user" }, + }; + + // Set TeamsChannelData if teamId or meetingId is provided + if (teamId != null || meetingId != null || tenantId != null) + { + TeamsChannelData channelData = new(); + if (teamId != null) + { + channelData.Team = new TeamInfo { Id = teamId }; + } + + if (meetingId != null) + { + channelData.Meeting = new TeamsMeetingInfo { Id = meetingId }; + } + + if (tenantId != null) + { + channelData.Tenant = new TenantInfo { Id = tenantId }; + } + + activity.ChannelData = channelData; + } + + // Create a stub adapter (BotAdapter is abstract, use SimpleAdapter) + SimpleAdapter adapter = new(); + TurnContext turnContext = new(adapter, activity); + + // Wire up CompatConnectorClient with real ConversationClient (same as TeamsBotFrameworkHttpAdapter does) + CompatConversations compatConversations = new(_f.ConversationClient) + { + ServiceUrl = _f.ServiceUrl.ToString(), + AgenticIdentity = _f.AgenticIdentity + }; + CompatConnectorClient connectorClient = new(compatConversations); + turnContext.TurnState.Add(connectorClient); + + // Wire up scoped ApiClient (same as TeamsBotFrameworkHttpAdapter does) + ApiClient scopedApi = _f.ScopedApiClient; + turnContext.TurnState.Add(scopedApi); + + return turnContext; + } + + #region Member Methods (non-team scope) + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetMemberAsync_ReturnsTeamsChannelAccount() + { + + // First get a valid MRI-format member ID + ApiClient api = _f.ScopedApiClient; + IList members = await api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + using TurnContext ctx = CreateTurnContext(); + TeamsChannelAccount result = await TeamsApiClient.GetMemberAsync(ctx, memberId); + + Assert.NotNull(result); + Assert.Equal(memberId, result.Id); + _output.WriteLine($"GetMember: {result.Id} — {result.Name}, Email: {result.Email}, UPN: {result.UserPrincipalName}"); + } + +#pragma warning disable CS0618 // Obsolete warning for GetMembersAsync + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetMembersAsync_ReturnsTeamsChannelAccounts() + { + + using TurnContext ctx = CreateTurnContext(); + IEnumerable result = await TeamsApiClient.GetMembersAsync(ctx); + + Assert.NotNull(result); + List members = [.. result]; + Assert.NotEmpty(members); + + foreach (TeamsChannelAccount m in members) + { + _output.WriteLine($"GetMembers: {m.Id} — {m.Name}"); + } + } +#pragma warning restore CS0618 + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetPagedMembersAsync_ReturnsPaged() + { + + using TurnContext ctx = CreateTurnContext(); + TeamsPagedMembersResult result = await TeamsApiClient.GetPagedMembersAsync(ctx, pageSize: 2); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.NotEmpty(result.Members); + + foreach (TeamsChannelAccount m in result.Members) + { + _output.WriteLine($"PagedMember: {m.Id} — {m.Name}"); + } + + _output.WriteLine($"ContinuationToken: {result.ContinuationToken ?? "(null)"}"); + } + + #endregion + + #region Team-scoped Member Methods + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetTeamMemberAsync_ReturnsTeamsChannelAccount() + { + + // Get a valid MRI-format member ID from the team + ApiClient api = _f.ScopedApiClient; + IList members = await api.Conversations.Members.GetAsync(_f.TeamId, _f.AgenticIdentity); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamsChannelAccount result = await TeamsApiClient.GetTeamMemberAsync(ctx, memberId, _f.TeamId); + + Assert.NotNull(result); + Assert.Equal(memberId, result.Id); + _output.WriteLine($"GetTeamMember: {result.Id} — {result.Name}, Email: {result.Email}"); + } + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetMemberAsync_WithTeamScope_DelegatesToGetTeamMember() + { + + // When activity has TeamInfo, GetMemberAsync should delegate to GetTeamMemberAsync + ApiClient api = _f.ScopedApiClient; + IList members = await api.Conversations.Members.GetAsync(_f.TeamId, _f.AgenticIdentity); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamsChannelAccount result = await TeamsApiClient.GetMemberAsync(ctx, memberId); + + Assert.NotNull(result); + Assert.Equal(memberId, result.Id); + _output.WriteLine($"GetMember (team scope): {result.Id} — {result.Name}"); + } + +#pragma warning disable CS0618 + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetTeamMembersAsync_ReturnsMembers() + { + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + IEnumerable result = await TeamsApiClient.GetTeamMembersAsync(ctx, _f.TeamId); + + Assert.NotNull(result); + List members = [.. result]; + Assert.NotEmpty(members); + + foreach (TeamsChannelAccount m in members) + { + _output.WriteLine($"TeamMember: {m.Id} — {m.Name}"); + } + } +#pragma warning restore CS0618 + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetPagedTeamMembersAsync_ReturnsPaged() + { + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamsPagedMembersResult result = await TeamsApiClient.GetPagedTeamMembersAsync(ctx, _f.TeamId, pageSize: 2); + + Assert.NotNull(result); + Assert.NotNull(result.Members); + Assert.NotEmpty(result.Members); + + foreach (TeamsChannelAccount m in result.Members) + { + _output.WriteLine($"PagedTeamMember: {m.Id} — {m.Name}"); + } + + _output.WriteLine($"ContinuationToken: {result.ContinuationToken ?? "(null)"}"); + } + + #endregion + + #region Team & Channel Methods + + [Fact(Timeout = 5000)] + public async Task GetTeamDetailsAsync_ReturnsDetails() + { + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamDetails result = await TeamsApiClient.GetTeamDetailsAsync(ctx, _f.TeamId); + + Assert.NotNull(result); + Assert.NotNull(result.Id); + Assert.NotNull(result.Name); + _output.WriteLine($"TeamDetails: {result.Id} — {result.Name}, AadGroupId: {result.AadGroupId}"); + } + + [Fact(Timeout = 5000)] + public async Task GetTeamDetailsAsync_InfersTeamIdFromActivity() + { + + // When teamId is null, it should be inferred from the activity's TeamsChannelData + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + TeamDetails result = await TeamsApiClient.GetTeamDetailsAsync(ctx); + + Assert.NotNull(result); + Assert.NotNull(result.Id); + _output.WriteLine($"TeamDetails (inferred): {result.Id} — {result.Name}"); + } + + [Fact(Timeout = 5000)] + public async Task GetTeamChannelsAsync_ReturnsChannels() + { + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + ConversationList result = await TeamsApiClient.GetTeamChannelsAsync(ctx, _f.TeamId); + + Assert.NotNull(result); + Assert.NotNull(result.Conversations); + Assert.NotEmpty(result.Conversations); + + foreach (ChannelInfo ch in result.Conversations) + { + _output.WriteLine($"Channel: {ch.Id} — {ch.Name}"); + } + } + + [Fact(Timeout = 5000)] + public async Task GetTeamChannelsAsync_InfersTeamIdFromActivity() + { + + using TurnContext ctx = CreateTurnContext(teamId: _f.TeamId); + ConversationList result = await TeamsApiClient.GetTeamChannelsAsync(ctx); + + Assert.NotNull(result); + Assert.NotNull(result.Conversations); + Assert.NotEmpty(result.Conversations); + _output.WriteLine($"Channels (inferred): {result.Conversations.Count} channels found"); + } + + #endregion + + #region Meeting Methods + + [Fact(Timeout = 5000)] + public async Task GetMeetingParticipantAsync_ReturnsParticipant() + { + + // The meetings participant API requires AAD object ID, not MRI/pairwise bot framework ID. + // Get the AAD object ID from a human member (bots don't have one). + ApiClient api = _f.ScopedApiClient; + IList members = await api.Conversations.Members.GetAsync(_f.ConversationId, _f.AgenticIdentity); + Assert.NotEmpty(members); + + string? aadObjectId = null; + foreach (CoreConversationAccount m in members) + { + var tm = await api.Conversations.Members + .GetByIdAsync(_f.ConversationId, m.Id!, _f.AgenticIdentity); + _output.WriteLine($"Member: {tm.Name} — AadObjectId: {tm.AadObjectId ?? "(null)"}, Properties: [{string.Join(", ", tm.Properties.Keys)}]"); + if (tm.AadObjectId is not null) + { + aadObjectId = tm.AadObjectId; + break; + } + } + + if (aadObjectId is null) + { + _output.WriteLine("SKIP: No members with AAD object ID found in test conversation"); + return; + } + + using TurnContext ctx = CreateTurnContext(meetingId: _f.MeetingId, tenantId: _f.TenantId); + TeamsMeetingParticipant result = await TeamsApiClient.GetMeetingParticipantAsync( + ctx, _f.MeetingId, aadObjectId, _f.TenantId); + + Assert.NotNull(result); + _output.WriteLine($"Participant: {result.User?.Id} — Role: {result.Meeting?.Role}, InMeeting: {result.Meeting?.InMeeting}"); + } + + #endregion + + #region Error Cases + + [Fact(Timeout = 5000)] + public async Task GetTeamDetailsAsync_ThrowsWithoutTeamScope() + { + // No teamId in activity and no explicit teamId parameter + using TurnContext ctx = CreateTurnContext(); + await Assert.ThrowsAsync( + () => TeamsApiClient.GetTeamDetailsAsync(ctx)); + } + + [Fact(Timeout = 5000)] + public async Task GetTeamChannelsAsync_ThrowsWithoutTeamScope() + { + using TurnContext ctx = CreateTurnContext(); + await Assert.ThrowsAsync( + () => TeamsApiClient.GetTeamChannelsAsync(ctx)); + } + + [Fact(Timeout = 5000)] + public async Task GetMemberAsync_ThrowsWithNullUserId() + { + using TurnContext ctx = CreateTurnContext(); + await Assert.ThrowsAsync( + () => TeamsApiClient.GetMemberAsync(ctx, null!)); + } + + #endregion + + /// + /// Minimal BotAdapter stub for creating TurnContext in tests. + /// + private sealed class SimpleAdapter : BotAdapter + { + public override Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken) + => Task.CompletedTask; + + public override Task SendActivitiesAsync(ITurnContext turnContext, Activity[] activities, CancellationToken cancellationToken) + => Task.FromResult(Array.Empty()); + + public override Task UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken) + => Task.FromResult(new ResourceResponse()); + } +} diff --git a/core/test/IntegrationTests/ConversationClientTests.cs b/core/test/IntegrationTests/ConversationClientTests.cs new file mode 100644 index 000000000..a09567bff --- /dev/null +++ b/core/test/IntegrationTests/ConversationClientTests.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Integration tests for core making real API calls. +/// +public class ConversationClientTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + + public ConversationClientTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _f.OutputHelper = output; + _output = output; + } + + [Fact(Timeout = 5000)] + public async Task SendActivity() + { + CoreActivity activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithServiceUrl(_f.ServiceUrl) + .WithConversation(new(_f.ConversationId)) + .WithProperty("text", $"[ConversationClient] SendActivity at `{DateTime.UtcNow:s}`") + .Build(); + + SendActivityResponse? res = await _f.ConversationClient.SendActivityAsync(activity); + + Assert.NotNull(res); + Assert.NotNull(res.Id); + _output.WriteLine($"Sent activity: {res.Id}"); + } + + [Fact(Timeout = 5000)] + public async Task UpdateActivity() + { + CoreActivity activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithServiceUrl(_f.ServiceUrl) + .WithConversation(new(_f.ConversationId)) + .WithProperty("text", $"[ConversationClient] Original at `{DateTime.UtcNow:s}`") + .Build(); + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + + CoreActivity updated = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithServiceUrl(_f.ServiceUrl) + .WithConversation(new(_f.ConversationId)) + .WithProperty("text", $"[ConversationClient] Updated at `{DateTime.UtcNow:s}`") + .Build(); + + UpdateActivityResponse res = await _f.ConversationClient.UpdateActivityAsync( + _f.ConversationId, sent.Id, updated, false, _f.AgenticIdentity); + + Assert.NotNull(res?.Id); + _output.WriteLine($"Updated activity: {res.Id}"); + } + + [Fact(Timeout = 5000)] + public async Task DeleteActivity() + { + CoreActivity activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithServiceUrl(_f.ServiceUrl) + .WithConversation(new(_f.ConversationId)) + .WithProperty("text", $"[ConversationClient] To delete at `{DateTime.UtcNow:s}`") + .Build(); + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + + await Task.Delay(2000); + + await _f.ConversationClient.DeleteActivityAsync( + _f.ConversationId, sent.Id, _f.ServiceUrl, _f.AgenticIdentity); + + _output.WriteLine($"Deleted activity: {sent.Id}"); + } + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetConversationMembers() + { + IList members = await _f.ConversationClient.GetConversationMembersAsync( + _f.ConversationId, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(members); + Assert.NotEmpty(members); + + foreach (ConversationAccount m in members) + { + _output.WriteLine($"Member: {m.Id} — {m.Name}"); + } + } + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetConversationMember() + { + // Get MRI-format member ID from the members list first + IList members = await _f.ConversationClient.GetConversationMembersAsync( + _f.ConversationId, _f.ServiceUrl, _f.AgenticIdentity); + Assert.NotEmpty(members); + string memberId = members[0].Id!; + + ConversationAccount member = await _f.ConversationClient.GetConversationMemberAsync( + _f.ConversationId, memberId, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(member); + Assert.Equal(memberId, member.Id); + _output.WriteLine($"Member: {member.Id} — {member.Name}"); + } + + [Fact(Timeout = 5000, Skip = "GET /members throttled on canary — cached fixture needed")] + public async Task GetPagedMembers() + { + PagedMembersResult result = await _f.ConversationClient.GetConversationPagedMembersAsync( + _f.ConversationId, _f.ServiceUrl, pageSize: 5, agenticIdentity: _f.AgenticIdentity); + + Assert.NotNull(result?.Members); + Assert.NotEmpty(result.Members); + + foreach (ConversationAccount m in result.Members) + { + _output.WriteLine($"Member: {m.Id} — {m.Name}"); + } + } + + [Fact] + public async Task AddAndDeleteReaction() + { + CoreActivity activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithServiceUrl(_f.ServiceUrl) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithConversation(new(_f.ConversationId)) + .WithProperty("text", $"[ConversationClient] Reaction test at `{DateTime.UtcNow:s}`") + .Build(); + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + + await _f.ConversationClient.AddReactionAsync( + _f.ConversationId, sent.Id, "like", _f.ServiceUrl, _f.AgenticIdentity); + _output.WriteLine("Added 'like' reaction"); + + await Task.Delay(1000); + + await _f.ConversationClient.DeleteReactionAsync( + _f.ConversationId, sent.Id, "like", _f.ServiceUrl, _f.AgenticIdentity); + _output.WriteLine("Removed 'like' reaction"); + } +} diff --git a/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs b/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs new file mode 100644 index 000000000..684c77c63 --- /dev/null +++ b/core/test/IntegrationTests/CreateConversationDiagnosticTests.cs @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Diagnostic tests exploring CreateConversation parameter combinations. +/// These tests document what the Teams Bot Framework API accepts and rejects, +/// capturing full request/response details including headers. +/// +public class CreateConversationDiagnosticTests : IClassFixture +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + + public CreateConversationDiagnosticTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _f.OutputHelper = output; + _output = output; + } + + private async Task<(string first, string? second, string? third)> GetMemberMrisAsync() + { + IList members = await _f.ConversationClient.GetConversationMembersAsync( + _f.ConversationId, _f.ServiceUrl, _f.AgenticIdentity); + return ( + members[0].Id!, + members.Count >= 2 ? members[1].Id : null, + members.Count >= 3 ? members[2].Id : null + ); + } + + /// + /// Sends a CreateConversation request using a raw HttpClient to capture full request/response details. + /// + private async Task SendDiagnosticRequestAsync(string label, ConversationParameters parameters) + { + string url = $"{_f.ServiceUrl.ToString().TrimEnd('/')}/v3/conversations"; + string requestBody = JsonSerializer.Serialize(parameters, JsonOpts); + + // Use the DI-configured HttpClient (has BotAuthenticationHandler for token) + HttpClient httpClient = _f.ServiceProvider.GetRequiredService() + .CreateClient("BotConversationClient"); + + using HttpRequestMessage request = new(HttpMethod.Post, url); + request.Content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"); + + if (_f.AgenticIdentity is not null) + { + request.Options.Set(new HttpRequestOptionsKey("AgenticIdentity"), _f.AgenticIdentity); + } + + _output.WriteLine($"=== {label} ==="); + _output.WriteLine($"POST {url}"); + _output.WriteLine($"Request body:\n{requestBody}"); + + using HttpResponseMessage response = await httpClient.SendAsync(request); + + string responseBody = await response.Content.ReadAsStringAsync(); + + _output.WriteLine($"\nHTTP {(int)response.StatusCode} {response.StatusCode}"); + + _output.WriteLine("\nResponse headers:"); + foreach (var header in response.Headers) + { + _output.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + } + foreach (var header in response.Content.Headers) + { + _output.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + } + + // Pretty-print JSON response + try + { + var parsed = JsonSerializer.Deserialize(responseBody); + + string pretty = JsonSerializer.Serialize(parsed, JsonOpts); + _output.WriteLine($"\nResponse body:\n{pretty}"); + } + catch + { + _output.WriteLine($"\nResponse body:\n{responseBody}"); + } + + _output.WriteLine(""); + + return new DiagnosticResult + { + Label = label, + StatusCode = (int)response.StatusCode, + RequestBody = requestBody, + ResponseBody = responseBody, + ResponseHeaders = response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)) + }; + } + + private record DiagnosticResult + { + public required string Label { get; init; } + public required int StatusCode { get; init; } + public required string RequestBody { get; init; } + public required string ResponseBody { get; init; } + public required Dictionary ResponseHeaders { get; init; } + } + + // ========================================================================= + // 1:1 personal chat — baseline (known working) + // ========================================================================= + + [Fact(Timeout = 5000)] + public async Task PersonalChat_MinimalParams() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("1:1 Personal Chat (minimal)", new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } + + [Fact(Timeout = 5000)] + public async Task PersonalChat_WithBot() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("1:1 Personal Chat (with bot)", new() + { + IsGroup = false, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } + + [Fact(Timeout = 5000)] + public async Task PersonalChat_WithInitialActivity() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("1:1 Personal Chat (with activity)", new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId, + Activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithProperty("text", "[Diagnostic] 1:1 with initial activity") + .Build() + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } + + // ========================================================================= + // Group chat variations + // ========================================================================= + + [Fact(Timeout = 5000)] + public async Task GroupChat_TwoMembers_NoBotNoChannelData() + { + (string first, string? second, _) = await GetMemberMrisAsync(); + Assert.NotNull(second); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 2 members, no bot, no channelData", new() + { + IsGroup = true, + Members = [new() { Id = first }, new() { Id = second! }], + TenantId = _f.TenantId + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact(Timeout = 5000)] + public async Task GroupChat_TwoMembers_WithBot() + { + (string first, string? second, _) = await GetMemberMrisAsync(); + Assert.NotNull(second); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 2 members, bot=28:appId", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = first }, new() { Id = second! }], + TenantId = _f.TenantId + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact(Timeout = 5000)] + public async Task GroupChat_TwoMembers_WithBotAndChannelData() + { + (string first, string? second, _) = await GetMemberMrisAsync(); + Assert.NotNull(second); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 2 members, bot, channelData.tenant", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = first }, new() { Id = second! }], + TenantId = _f.TenantId, + ChannelData = new { tenant = new { id = _f.TenantId } } + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact(Timeout = 5000)] + public async Task GroupChat_TwoMembers_WithTopicAndActivity() + { + (string first, string? second, _) = await GetMemberMrisAsync(); + Assert.NotNull(second); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 2 members, bot, topic, activity, channelData", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = first }, new() { Id = second! }], + TenantId = _f.TenantId, + TopicName = "Diagnostic group test", + ChannelData = new { tenant = new { id = _f.TenantId } }, + Activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithProperty("text", "group chat init") + .Build() + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact(Timeout = 5000)] + public async Task GroupChat_OneMember_IsGroupTrue() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 1 member, isGroup=true", new() + { + IsGroup = true, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact(Timeout = 5000)] + public async Task GroupChat_OneMember_WithBot() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 1 member, bot, channelData.tenant", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId, + ChannelData = new { tenant = new { id = _f.TenantId } } + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact(Timeout = 5000)] + public async Task GroupChat_ThreeMembers() + { + (string first, string? second, string? third) = await GetMemberMrisAsync(); + Assert.NotNull(second); + Assert.NotNull(third); + DiagnosticResult result = await SendDiagnosticRequestAsync("Group Chat: 3 members, bot", new() + { + IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = [new() { Id = first }, new() { Id = second! }, new() { Id = third! }], + TenantId = _f.TenantId, + ChannelData = new { tenant = new { id = _f.TenantId } } + }); + Assert.Equal(400, result.StatusCode); + } + + // ========================================================================= + // Channel thread variations + // ========================================================================= + + [Fact(Timeout = 5000)] + public async Task ChannelThread_WithActivity() + { + DiagnosticResult result = await SendDiagnosticRequestAsync("Channel Thread: with activity", new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithProperty("text", "[Diagnostic] channel thread") + .Build(), + TenantId = _f.TenantId + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } + + [Fact(Timeout = 5000)] + public async Task ChannelThread_NoActivity() + { + DiagnosticResult result = await SendDiagnosticRequestAsync("Channel Thread: without activity", new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + TenantId = _f.TenantId + }); + Assert.Equal(400, result.StatusCode); + } + + [Fact(Timeout = 5000)] + public async Task ChannelThread_WithMembersAndActivity() + { + (string memberMri, _, _) = await GetMemberMrisAsync(); + DiagnosticResult result = await SendDiagnosticRequestAsync("Channel Thread: with members and activity", new() + { + IsGroup = true, + Members = [new() { Id = memberMri }], + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithProperty("text", "[Diagnostic] channel thread with members") + .Build(), + TenantId = _f.TenantId + }); + Assert.True(result.StatusCode is 200 or 201, $"Expected 2xx, got {result.StatusCode}"); + } +} diff --git a/core/test/IntegrationTests/CreateConversationTests.cs b/core/test/IntegrationTests/CreateConversationTests.cs new file mode 100644 index 000000000..c109db779 --- /dev/null +++ b/core/test/IntegrationTests/CreateConversationTests.cs @@ -0,0 +1,369 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Integration tests for creating conversations with different ConversationParameters. +/// Tests personal chats, group chats, and channel thread creation via both +/// core and the facade. +/// +public class CreateConversationTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + private readonly ApiClient _api; + + public CreateConversationTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _f.OutputHelper = output; + _output = output; + _api = _f.ScopedApiClient; + } + + /// + /// Gets MRI-format member IDs by fetching the conversation members list. + /// The API requires MRI IDs (e.g., "29:1abc..."), not pairwise bot framework IDs. + /// + private async Task<(string first, string? second)> GetMemberMrisAsync() + { + IList members = await _f.ConversationClient.GetConversationMembersAsync( + _f.ConversationId, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.True(members.Count >= 1, "Need at least 1 member in the test conversation"); + + string first = members[0].Id!; + string? second = members.Count >= 2 ? members[1].Id : null; + + _output.WriteLine($"Using member MRIs: first={first}, second={second ?? "(none)"}"); + return (first, second); + } + + #region Personal Chat (1:1) — Core ConversationClient + + [Fact(Timeout = 5000)] + public async Task Core_CreatePersonalChat() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"Created 1:1 conversation: {response.Id}"); + } + + [Fact(Timeout = 5000)] + public async Task Core_CreatePersonalChat_AndSendMessage() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response?.Id); + + CoreActivity activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithServiceUrl(_f.ServiceUrl) + .WithConversation(new(response.Id)) + .WithProperty("text", $"[Core] 1:1 message at `{DateTime.UtcNow:s}`") + .Build(); + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + _output.WriteLine($"Created 1:1 conversation {response.Id} and sent activity {sent.Id}"); + } + + [Fact(Timeout = 5000)] + public async Task Core_CreatePersonalChat_WithInitialActivity() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId, + Activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithProperty("text", $"[Core] Initial message at `{DateTime.UtcNow:s}`") + .Build() + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"Created 1:1 conversation with initial activity: {response.Id}, activityId: {response.ActivityId}"); + } + + #endregion + + #region Group Chat — Core ConversationClient + + [Fact] + public async Task Core_CreateGroupChat() + { + (string first, string? second) = await GetMemberMrisAsync(); + if (second is null) + { + _output.WriteLine("Skipping: need at least 2 members in conversation"); + return; + } + + ConversationParameters parameters = new() + { + //IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = + [ + //new() { Id = first }, + new() { Id = second } + ], + TenantId = _f.TenantId, + //TopicName = $"Integration Test Group - {DateTime.UtcNow:s}", + ChannelData = new { tenant = new { id = _f.TenantId } } + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"Created group conversation: {response.Id}"); + } + + [Fact] + public async Task Core_CreateGroupChat_AndSendMessage() + { + (string first, string? second) = await GetMemberMrisAsync(); + if (second is null) + { + _output.WriteLine("Skipping: need at least 2 members in conversation"); + return; + } + + ConversationParameters parameters = new() + { + IsGroup = false, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = + [ + //new() { Id = first }, + new() { Id = second } + ], + TenantId = _f.TenantId, + ChannelData = new { tenant = new { id = _f.TenantId } } + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response?.Id); + + CoreActivity activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithServiceUrl(_f.ServiceUrl) + .WithConversation(new(response.Id)) + .WithProperty("text", $"[Core] Group message at `{DateTime.UtcNow:s}`") + .Build(); + + SendActivityResponse? sent = await _f.ConversationClient.SendActivityAsync(activity); + Assert.NotNull(sent?.Id); + _output.WriteLine($"Created group {response.Id} and sent activity {sent.Id}"); + } + + #endregion + + #region Channel Thread — Core ConversationClient + + [Fact(Timeout = 5000)] + public async Task Core_CreateChannelThread() + { + ConversationParameters parameters = new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithProperty("text", $"[Core] New channel thread at `{DateTime.UtcNow:s}`") + .Build(), + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _f.ConversationClient.CreateConversationAsync( + parameters, _f.ServiceUrl, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"Created channel thread: {response.Id}, activityId: {response.ActivityId}"); + } + + #endregion + + #region Personal Chat — ApiClient + + [Fact(Timeout = 5000)] + public async Task ApiClient_CreatePersonalChat() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"[ApiClient] Created 1:1 conversation: {response.Id}"); + } + + [Fact(Timeout = 5000)] + public async Task ApiClient_CreatePersonalChat_AndSendViaActivities() + { + (string memberMri, _) = await GetMemberMrisAsync(); + + ConversationParameters parameters = new() + { + IsGroup = false, + Members = [new() { Id = memberMri }], + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters, _f.AgenticIdentity); + Assert.NotNull(response?.Id); + + CoreActivity activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithProperty("text", $"[ApiClient] 1:1 via Activities.Create at `{DateTime.UtcNow:s}`") + .Build(); + + SendActivityResponse? sent = await _api.Conversations.Activities.CreateAsync(response.Id, activity); + Assert.NotNull(sent?.Id); + _output.WriteLine($"[ApiClient] Created 1:1 {response.Id}, sent activity {sent.Id}"); + } + + #endregion + + #region Group Chat — ApiClient + + [Fact] + public async Task ApiClient_CreateGroupChat() + { + (string first, string? second) = await GetMemberMrisAsync(); + if (second is null) + { + _output.WriteLine("Skipping: need at least 2 members in conversation"); + return; + } + + ConversationParameters parameters = new() + { + //IsGroup = true, + Bot = new() { Id = $"28:{_f.BotAppId}" }, + Members = + [ + new() { Id = first }, + new() { Id = second } + ], + TenantId = _f.TenantId, + TopicName = $"[ApiClient] Group - {DateTime.UtcNow:s}", + ChannelData = new { tenant = new { id = _f.TenantId } } + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"[ApiClient] Created group conversation: {response.Id}"); + } + + #endregion + + #region Channel Thread — ApiClient + + [Fact(Timeout = 5000)] + public async Task ApiClient_CreateChannelThread() + { + ConversationParameters parameters = new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithProperty("text", $"[ApiClient] New channel thread at `{DateTime.UtcNow:s}`") + .Build(), + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters, _f.AgenticIdentity); + + Assert.NotNull(response); + Assert.NotNull(response.Id); + _output.WriteLine($"[ApiClient] Created channel thread: {response.Id}, activityId: {response.ActivityId}"); + } + + [Fact(Timeout = 5000)] + public async Task ApiClient_CreateChannelThread_AndReply() + { + ConversationParameters parameters = new() + { + IsGroup = true, + ChannelData = new { channel = new { id = _f.ChannelId } }, + Activity = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithProperty("text", $"[ApiClient] Thread root at `{DateTime.UtcNow:s}`") + .Build(), + TenantId = _f.TenantId + }; + + CreateConversationResponse response = await _api.Conversations.CreateAsync(parameters, _f.AgenticIdentity); + Assert.NotNull(response?.Id); + Assert.NotNull(response.ActivityId); + + // Reply to the thread + CoreActivity reply = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetConversationAccountWithAgenticProperties()) + .WithProperty("text", $"[ApiClient] Thread reply at `{DateTime.UtcNow:s}`") + .Build(); + + SendActivityResponse? replyResponse = await _api.Conversations.Activities.ReplyAsync( + response.Id, response.ActivityId, reply); + + Assert.NotNull(replyResponse); + _output.WriteLine($"[ApiClient] Created thread {response.Id}, root activity {response.ActivityId}, reply {replyResponse?.Id}"); + } + + #endregion +} diff --git a/core/test/IntegrationTests/IntegrationTestFixture.cs b/core/test/IntegrationTests/IntegrationTestFixture.cs new file mode 100644 index 000000000..91baad381 --- /dev/null +++ b/core/test/IntegrationTests/IntegrationTestFixture.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using MartinCostello.Logging.XUnit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Shared fixture that configures DI, acquires tokens, and exposes clients for integration tests. +/// Reused across test classes via IClassFixture to avoid repeated token acquisition. +/// +public class IntegrationTestFixture : IDisposable, ITestOutputHelperAccessor +{ + public ServiceProvider ServiceProvider { get; } + public ConversationClient ConversationClient { get; } + public ApiClient ApiClient { get; } + + public Uri ServiceUrl { get; } + public string ConversationId { get; } + public string UserId { get; } + public string TeamId { get; } + public string ChannelId { get; } + public string MeetingId { get; } + public string TenantId { get; } + public string BotAppId { get; } + public string? UserId2 { get; } + public AgenticIdentity? AgenticIdentity { get; } + + /// + /// Set by each test class constructor to route ILogger output to xUnit's test output. + /// + public ITestOutputHelper? OutputHelper { get; set; } + + public IntegrationTestFixture() + { + IConfiguration configuration = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddEnvironmentVariables() + .Build(); + + ServiceCollection services = new(); + services.AddLogging(builder => + { + builder.AddXUnit(this); + builder.AddFilter("System.Net", LogLevel.Warning); + builder.AddFilter("Microsoft.Identity", LogLevel.Error); + builder.AddFilter("Microsoft.Teams", LogLevel.Information); + }); + services.AddSingleton(configuration); + services.AddTeamsBotApplication(); + + ServiceProvider = services.BuildServiceProvider(); + ConversationClient = ServiceProvider.GetRequiredService(); + ApiClient = ServiceProvider.GetRequiredService(); + + ServiceUrl = new Uri(Env("TEST_SERVICEURL", "https://smba.trafficmanager.net/teams/")); + ConversationId = Env("TEST_CONVERSATIONID"); + UserId = Env("TEST_USER_ID"); + TeamId = Env("TEST_TEAMID"); + ChannelId = Env("TEST_CHANNELID"); + MeetingId = Env("TEST_MEETINGID"); + TenantId = Env("TEST_TENANTID"); + BotAppId = Env("AzureAd__ClientId"); + UserId2 = Environment.GetEnvironmentVariable("TEST_USER_ID_2"); + + string? agenticAppId = Environment.GetEnvironmentVariable("TEST_AGENTIC_APPID"); + string? agenticUserId = Environment.GetEnvironmentVariable("TEST_AGENTIC_USERID"); + + if (!string.IsNullOrEmpty(agenticAppId) && !string.IsNullOrEmpty(agenticUserId)) + { + string appBlueprintId = Env("AzureAd__ClientId"); + ConversationAccount recipient = new() + { + AgenticAppBlueprintId = appBlueprintId, + AgenticAppId = agenticAppId, + AgenticUserId = agenticUserId + }; + AgenticIdentity = AgenticIdentity.FromAccount(recipient); + } + } + + public ApiClient ScopedApiClient => ApiClient.ForServiceUrl(ServiceUrl); + + public void Dispose() + { + ServiceProvider.Dispose(); + GC.SuppressFinalize(this); + } + + private static string Env(string name, string? fallback = null) => + Environment.GetEnvironmentVariable(name) + ?? fallback + ?? throw new InvalidOperationException($"{name} environment variable not set"); + + internal static ConversationAccount GetConversationAccountWithAgenticProperties() + { + var agenticUserId = Env("TEST_AGENTIC_USERID"); + var agenticAppId = Env("TEST_AGENTIC_APPID"); + var agenticAppBlueprintId = Env("AzureAd__ClientId"); + + if (string.IsNullOrEmpty(agenticUserId)) + { + return new ConversationAccount(); + } + + ConversationAccount account = new() + { + Id = agenticUserId, + Name = "Agentic User", + AgenticAppBlueprintId = agenticAppBlueprintId, + AgenticAppId = agenticAppId, + AgenticUserId = agenticUserId + }; + return account; + } + + internal static AgenticIdentity GetAgenticIdentity() + { + var agenticUserId = Env("TEST_AGENTIC_USERID"); + var agenticAppId = Env("TEST_AGENTIC_APPID"); + var agenticAppBlueprintId = Env("AzureAd__ClientId"); + + if (string.IsNullOrEmpty(agenticUserId)) + { + return null!; + } + + AgenticIdentity identity = new() + { + AgenticUserId = agenticUserId, + AgenticAppId = agenticAppId, + AgenticAppBlueprintId = agenticAppBlueprintId + }; + return identity; + } +} diff --git a/core/test/IntegrationTests/IntegrationTests.csproj b/core/test/IntegrationTests/IntegrationTests.csproj new file mode 100644 index 000000000..e34aa10c5 --- /dev/null +++ b/core/test/IntegrationTests/IntegrationTests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + $(NoWarn);ExperimentalTeamsTargeted;ExperimentalTeamsReactions + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/test/IntegrationTests/xunit.runner.json b/core/test/IntegrationTests/xunit.runner.json new file mode 100644 index 000000000..08c512b3d --- /dev/null +++ b/core/test/IntegrationTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": false +} diff --git a/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatActivityTests.cs b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatActivityTests.cs new file mode 100644 index 000000000..203a4617f --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatActivityTests.cs @@ -0,0 +1,438 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using AdaptiveCards; +using Microsoft.Bot.Schema; +using Microsoft.Teams.Core.Schema; +using Newtonsoft.Json; + +namespace Microsoft.Teams.Apps.BotBuilder.UnitTests +{ + public class ActivitySchemaMapperTests + { + #region Core Properties Tests + + [Fact] + public void FromBotFrameworkActivity_PreservesCoreProperties() + { + Activity activity = new() + { + Type = ActivityTypes.Message, + ServiceUrl = "https://smba.trafficmanager.net/teams", + ChannelId = "msteams", + Id = "test-id-123", + From = new ChannelAccount { Id = "user-123", Name = "Test User" }, + Recipient = new ChannelAccount { Id = "bot-456", Name = "Test Bot" }, + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "conv-789", Name = "Test Conversation" } + }; + + CoreActivity coreActivity = activity.FromBotFrameworkActivity(); + + Assert.NotNull(coreActivity); + Assert.Equal(activity.Type, coreActivity.Type); + Assert.Equal(activity.ServiceUrl, coreActivity.ServiceUrl?.ToString()); + Assert.Equal(activity.ChannelId, coreActivity.ChannelId); + Assert.Equal(activity.Id, coreActivity.Id); + Assert.Equal(activity.From?.Id, coreActivity.From?.Id); + Assert.Equal(activity.From?.Name, coreActivity.From?.Name); + Assert.Equal(activity.Recipient?.Id, coreActivity.Recipient?.Id); + Assert.Equal(activity.Conversation?.Id, coreActivity.Conversation?.Id); + } + + [Fact] + public void FromBotFrameworkActivity_PreservesTextAndMetadata() + { + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Hello, this is a test message", + TextFormat = "plain", + Locale = "en-US", + InputHint = "acceptingInput", + ReplyToId = "reply-to-123" + }; + + CoreActivity coreActivity = activity.FromBotFrameworkActivity(); + + Assert.NotNull(coreActivity); + Assert.Equal(activity.Text, coreActivity.Properties["text"]?.ToString()); + Assert.Equal(activity.InputHint, coreActivity.Properties["inputHint"]?.ToString()); + Assert.Equal(activity.ReplyToId, coreActivity.ReplyToId); + Assert.Equal(activity.Locale, coreActivity.Properties["locale"]?.ToString()); + } + + #endregion + + #region Attachments Tests + + [Fact] + public void FromBotFrameworkActivity_PreservesAdaptiveCardAttachment() + { + string json = LoadTestData("AdaptiveCardActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + Assert.NotNull(botActivity); + Assert.Single(botActivity.Attachments); + + CoreActivity coreActivity = botActivity.FromBotFrameworkActivity(); + + Assert.NotNull(coreActivity); + JsonArray? attachments = coreActivity.Properties.Extract("attachments"); + Assert.NotNull(attachments); + Assert.Single(attachments); + + JsonNode? attachmentNode = attachments[0]; + Assert.NotNull(attachmentNode); + JsonObject attachmentObj = attachmentNode.AsObject(); + + string? contentType = attachmentObj["contentType"]?.GetValue(); + Assert.Equal("application/vnd.microsoft.card.adaptive", contentType); + + JsonNode? content = attachmentObj["content"]; + Assert.NotNull(content); + AdaptiveCard card = AdaptiveCard.FromJson(content.ToJsonString()).Card; + Assert.Equal(2, card.Body?.Count); + AdaptiveTextBlock? firstTextBlock = card?.Body?[0] as AdaptiveTextBlock; + Assert.NotNull(firstTextBlock); + Assert.Equal("Mention a user by User Principle Name: Hello Test User UPN", firstTextBlock.Text); + } + + [Fact] + public void FromBotFrameworkActivity_PreservesMultipleAttachments() + { + Activity activity = new() + { + Type = ActivityTypes.Message, + Attachments = new List + { + new() { ContentType = "text/plain", Content = "First attachment" }, + new() { ContentType = "image/png", ContentUrl = "https://example.com/image.png" } + } + }; + + CoreActivity coreActivity = activity.FromBotFrameworkActivity(); + + JsonArray? attachments = coreActivity.Properties.Extract("attachments"); + Assert.NotNull(attachments); + Assert.Equal(2, attachments?.Count); + Assert.Equal("text/plain", attachments?[0]?["contentType"]?.GetValue()); + Assert.Equal("image/png", attachments?[1]?["contentType"]?.GetValue()); + } + + #endregion + + #region Entities Tests + + [Fact] + public void FromBotFrameworkActivity_PreservesEntities() + { + string json = LoadTestData("AdaptiveCardActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + + CoreActivity coreActivity = botActivity.FromBotFrameworkActivity(); + + JsonArray? entities = coreActivity.Properties.Extract("entities"); + Assert.NotNull(entities); + Assert.Single(entities); + + JsonObject? entity = entities[0]?.AsObject(); + Assert.NotNull(entity); + Assert.Equal("https://schema.org/Message", entity["type"]?.GetValue()); + } + + [Fact] + public void FromBotFrameworkActivity_PreservesMultipleEntities() + { + string json = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + + CoreActivity coreActivity = botActivity.FromBotFrameworkActivity(); + + JsonArray? entities = coreActivity.Properties.Extract("entities"); + Assert.NotNull(entities); + Assert.Equal(2, entities?.Count); + + JsonObject? firstEntity = entities?[0]?.AsObject(); + Assert.Equal("https://schema.org/Message", firstEntity?["type"]?.GetValue()); + + JsonObject? secondEntity = entities?[1]?.AsObject(); + Assert.Equal("BotMessageMetadata", secondEntity?["type"]?.GetValue()); + } + + #endregion + + #region SuggestedActions Tests + + [Fact] + public void FromBotFrameworkActivity_PreservesSuggestedActions() + { + string json = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + Assert.NotNull(botActivity.SuggestedActions); + Assert.Equal(3, botActivity.SuggestedActions.Actions.Count); + + CoreActivity coreActivity = botActivity.FromBotFrameworkActivity(); + + Assert.True(coreActivity.Properties.ContainsKey("suggestedActions")); + + string coreActivityJson = coreActivity.ToJson(); + JsonNode coreActivityNode = JsonNode.Parse(coreActivityJson)!; + + JsonNode? suggestedActions = coreActivityNode["suggestedActions"]; + Assert.NotNull(suggestedActions); + + JsonArray? actions = suggestedActions["actions"]?.AsArray(); + Assert.NotNull(actions); + Assert.Equal(3, actions.Count); + } + + [Fact] + public void FromBotFrameworkActivity_PreservesSuggestedActionDetails() + { + string json = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + + CoreActivity coreActivity = botActivity.FromBotFrameworkActivity(); + string coreActivityJson = coreActivity.ToJson(); + JsonNode coreActivityNode = JsonNode.Parse(coreActivityJson)!; + + JsonArray? actions = coreActivityNode["suggestedActions"]?["actions"]?.AsArray(); + Assert.NotNull(actions); + + // Verify Action.Odsl actions + Assert.Equal("Action.Odsl", actions[0]?["type"]?.GetValue()); + Assert.Equal("Add reviewers", actions[0]?["title"]?.GetValue()); + Assert.NotNull(actions[0]?["value"]); + + Assert.Equal("Action.Odsl", actions[1]?["type"]?.GetValue()); + Assert.Equal("Open agent settings", actions[1]?["title"]?.GetValue()); + + // Verify Action.Compose action + Assert.Equal("Action.Compose", actions[2]?["type"]?.GetValue()); + Assert.Equal("Ask me a question", actions[2]?["title"]?.GetValue()); + Assert.NotNull(actions[2]?["value"]); + } + + #endregion + + #region ChannelData Tests + + [Fact] + public void FromBotFrameworkActivity_PreservesChannelData() + { + Activity activity = new() + { + Type = ActivityTypes.Message, + ChannelData = new { customProperty = "customValue", nestedObject = new { key = "value" } } + }; + + CoreActivity coreActivity = activity.FromBotFrameworkActivity(); + + ChannelData? channelData = coreActivity.Properties.Extract("channelData"); + Assert.NotNull(channelData); + Assert.True(channelData.Properties.ContainsKey("customProperty")); + Assert.Equal("customValue", channelData.Properties["customProperty"]?.ToString()); + } + + [Fact] + public void FromBotFrameworkActivity_PreservesComplexChannelData() + { + string json = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(json)!; + + CoreActivity coreActivity = botActivity.FromBotFrameworkActivity(); + + ChannelData? channelData = coreActivity.Properties.Extract("channelData"); + Assert.NotNull(channelData); + Assert.True(channelData.Properties.ContainsKey("feedbackLoopEnabled")); + + JsonElement feedbackLoopValue = (JsonElement)channelData.Properties["feedbackLoopEnabled"]!; + Assert.True(feedbackLoopValue.GetBoolean()); + } + + #endregion + + #region Integration Tests + + [Fact] + public void FromBotFrameworkActivity_CompleteRoundTrip_AdaptiveCard() + { + // Verify the complete adaptive card payload round-trips successfully + string originalJson = LoadTestData("AdaptiveCardActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(originalJson)!; + + CoreActivity coreActivity = botActivity.FromBotFrameworkActivity(); + string coreActivityJson = coreActivity.ToJson(); + + // Use JsonNode.DeepEquals to verify structural equality + JsonNode originalNode = JsonNode.Parse(originalJson)!; + JsonNode coreNode = JsonNode.Parse(coreActivityJson)!; + + Assert.True(JsonNode.DeepEquals(originalNode, coreNode)); + } + + [Fact] + public void FromBotFrameworkActivity_CompleteRoundTrip_SuggestedActions() + { + // Verify the complete suggested actions payload round-trips successfully + string originalJson = LoadTestData("SuggestedActionsActivity.json"); + Activity botActivity = JsonConvert.DeserializeObject(originalJson)!; + + CoreActivity coreActivity = botActivity.FromBotFrameworkActivity(); + string coreActivityJson = coreActivity.ToJson(); + + // Use JsonNode.DeepEquals to verify structural equality + JsonNode originalNode = JsonNode.Parse(originalJson)!; + JsonNode coreNode = JsonNode.Parse(coreActivityJson)!; + + Assert.True(JsonNode.DeepEquals(originalNode, coreNode)); + } + + #endregion + + private static string LoadTestData(string fileName) + { + string testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData", fileName); + return File.ReadAllText(testDataPath); + } + } + + public class FromCompatChannelAccountTests + { + [Fact] + public void FromCompatChannelAccount_MapsIdAndName() + { + Microsoft.Bot.Schema.ChannelAccount account = new() { Id = "user-1", Name = "Alice" }; + + Microsoft.Teams.Core.Schema.ConversationAccount result = account.FromCompatChannelAccount(); + + Assert.Equal("user-1", result.Id); + Assert.Equal("Alice", result.Name); + } + + [Fact] + public void FromCompatChannelAccount_MapsAadObjectIdToProperties() + { + Microsoft.Bot.Schema.ChannelAccount account = new() { Id = "user-1", AadObjectId = "aad-123" }; + + Microsoft.Teams.Core.Schema.ConversationAccount result = account.FromCompatChannelAccount(); + + Assert.True(result.Properties.TryGetValue("aadObjectId", out object? val)); + Assert.Equal("aad-123", val?.ToString()); + } + + [Fact] + public void FromCompatChannelAccount_MapsRoleToUserRoleInProperties() + { + Microsoft.Bot.Schema.ChannelAccount account = new() { Id = "user-1", Role = "owner" }; + + Microsoft.Teams.Core.Schema.ConversationAccount result = account.FromCompatChannelAccount(); + + Assert.True(result.Properties.TryGetValue("userRole", out object? val)); + Assert.Equal("owner", val?.ToString()); + } + + [Fact] + public void FromCompatChannelAccount_SkipsNullAadObjectIdAndRole() + { + Microsoft.Bot.Schema.ChannelAccount account = new() { Id = "user-1" }; + + Microsoft.Teams.Core.Schema.ConversationAccount result = account.FromCompatChannelAccount(); + + Assert.False(result.Properties.ContainsKey("aadObjectId")); + Assert.False(result.Properties.ContainsKey("userRole")); + } + + [Fact] + public void FromCompatChannelAccount_ThrowsOnNull() + { + Microsoft.Bot.Schema.ChannelAccount? account = null; + Assert.Throws(() => account!.FromCompatChannelAccount()); + } + } + + public class FromCompatConversationParametersTests + { + [Fact] + public void FromCompatConversationParameters_MapsAllScalarFields() + { + Microsoft.Bot.Schema.ConversationParameters parameters = new() + { + IsGroup = true, + TopicName = "Test Topic", + TenantId = "tenant-abc", + ChannelData = new { custom = "data" }, + }; + + Microsoft.Teams.Core.ConversationParameters result = parameters.FromCompatConversationParameters(); + + Assert.True(result.IsGroup); + Assert.Equal("Test Topic", result.TopicName); + Assert.Equal("tenant-abc", result.TenantId); + Assert.NotNull(result.ChannelData); + } + + [Fact] + public void FromCompatConversationParameters_MapsBotAccount() + { + Microsoft.Bot.Schema.ConversationParameters parameters = new() + { + Bot = new Microsoft.Bot.Schema.ChannelAccount { Id = "bot-1", Name = "MyBot" } + }; + + Microsoft.Teams.Core.ConversationParameters result = parameters.FromCompatConversationParameters(); + + Assert.NotNull(result.Bot); + Assert.Equal("bot-1", result.Bot.Id); + Assert.Equal("MyBot", result.Bot.Name); + } + + [Fact] + public void FromCompatConversationParameters_MapsMembers() + { + Microsoft.Bot.Schema.ConversationParameters parameters = new() + { + Members = + [ + new Microsoft.Bot.Schema.ChannelAccount { Id = "user-1", Name = "Alice" }, + new Microsoft.Bot.Schema.ChannelAccount { Id = "user-2", Name = "Bob" }, + ] + }; + + Microsoft.Teams.Core.ConversationParameters result = parameters.FromCompatConversationParameters(); + + Assert.NotNull(result.Members); + Assert.Equal(2, result.Members.Count); + Assert.Equal("user-1", result.Members[0].Id); + Assert.Equal("user-2", result.Members[1].Id); + } + + [Fact] + public void FromCompatConversationParameters_NullActivityProducesNullActivity() + { + Microsoft.Bot.Schema.ConversationParameters parameters = new() { Activity = null }; + + Microsoft.Teams.Core.ConversationParameters result = parameters.FromCompatConversationParameters(); + + Assert.Null(result.Activity); + } + + [Fact] + public void FromCompatConversationParameters_NullBotProducesNullBot() + { + Microsoft.Bot.Schema.ConversationParameters parameters = new() { Bot = null }; + + Microsoft.Teams.Core.ConversationParameters result = parameters.FromCompatConversationParameters(); + + Assert.Null(result.Bot); + } + + [Fact] + public void FromCompatConversationParameters_ThrowsOnNull() + { + Microsoft.Bot.Schema.ConversationParameters? parameters = null; + Assert.Throws(() => parameters!.FromCompatConversationParameters()); + } + } +} diff --git a/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatAdapterTests.cs b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatAdapterTests.cs new file mode 100644 index 000000000..e99b315eb --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatAdapterTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core; +using Moq; + +namespace Microsoft.Teams.Apps.BotBuilder.UnitTests +{ + public class TeamsBotFrameworkHttpAdapterTests + { + [Fact] + public async Task ContinueConversationAsync_WhenCastToBotAdapter_BuildsTurnContextWithUnderlyingClients() + { + // Arrange + TeamsBotFrameworkHttpAdapter compatAdapter = CreateCompatAdapter(); + + // Cast to BotAdapter to ensure we're using the base class method + BotAdapter botAdapter = compatAdapter; + + ConversationReference conversationReference = new() + { + ServiceUrl = "https://smba.trafficmanager.net/teams", + ChannelId = "msteams", + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "test-conversation-id" } + }; + + bool callbackInvoked = false; + Microsoft.Bot.Connector.Authentication.UserTokenClient? capturedUserTokenClient = null; + Microsoft.Bot.Connector.IConnectorClient? capturedConnectorClient = null; + + BotCallbackHandler callback = async (turnContext, cancellationToken) => + { + callbackInvoked = true; + capturedUserTokenClient = turnContext.TurnState.Get(); + capturedConnectorClient = turnContext.TurnState.Get(); + await Task.CompletedTask; + }; + + // Act + await botAdapter.ContinueConversationAsync( + "test-bot-id", + conversationReference, + callback, + CancellationToken.None); + + // Assert + Assert.True(callbackInvoked); + + // Verify UserTokenClient is CompatUserTokenClient (check by type name since it's internal) + Assert.NotNull(capturedUserTokenClient); + Assert.Equal("CompatUserTokenClient", capturedUserTokenClient.GetType().Name); + Assert.IsAssignableFrom(capturedUserTokenClient); + + // Verify ConnectorClient is CompatConnectorClient (check by type name since it's internal) + Assert.NotNull(capturedConnectorClient); + Assert.Equal("CompatConnectorClient", capturedConnectorClient.GetType().Name); + Assert.IsAssignableFrom(capturedConnectorClient); + } + + private static TeamsBotFrameworkHttpAdapter CreateCompatAdapter() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + Mock mockConfig = new(); + mockConfig.Setup(c => c["UserTokenApiEndpoint"]).Returns("https://token.botframework.com"); + + UserTokenClient userTokenClient = new(httpClient, mockConfig.Object, NullLogger.Instance); + + BotApplication botApplication = new( + conversationClient, + userTokenClient, + NullLogger.Instance); + + TeamsBotFrameworkHttpAdapter compatAdapter = new( + botApplication, + Mock.Of(), + NullLogger.Instance); + + return compatAdapter; + } + } +} diff --git a/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatBotAdapterTests.cs b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatBotAdapterTests.cs new file mode 100644 index 000000000..e06cb9980 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatBotAdapterTests.cs @@ -0,0 +1,332 @@ +// 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.Abstractions; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Moq; + +namespace Microsoft.Teams.Apps.BotBuilder.UnitTests +{ + public class TeamsBotAdapterTests + { + [Fact] + public async Task DeleteActivityAsync_UsesConversationReferenceValues() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + TeamsBotAdapter adapter = CreateCompatBotAdapter(mockConversationClient.Object); + + ConversationReference reference = new() + { + ActivityId = "activity-123", + ServiceUrl = "https://smba.trafficmanager.net/teams/", + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "conversation-456" }, + ChannelId = "msteams" + }; + + ITurnContext turnContext = CreateMockTurnContext("https://different-service-url.com/"); + + // Act + await adapter.DeleteActivityAsync(turnContext, reference, CancellationToken.None); + + // Assert + mockConversationClient.Verify( + c => c.DeleteActivityAsync( + "conversation-456", + "activity-123", + It.Is(u => u.ToString().TrimEnd('/') == "https://smba.trafficmanager.net/teams"), + It.IsAny(), + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DeleteActivityAsync_FallsBackToTurnContextServiceUrl_WhenReferenceServiceUrlIsNull() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + TeamsBotAdapter adapter = CreateCompatBotAdapter(mockConversationClient.Object); + + ConversationReference reference = new() + { + ActivityId = "activity-123", + ServiceUrl = null, // Not set in reference + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "conversation-456" }, + ChannelId = "msteams" + }; + + ITurnContext turnContext = CreateMockTurnContext("https://fallback-service-url.com/"); + + // Act + await adapter.DeleteActivityAsync(turnContext, reference, CancellationToken.None); + + // Assert + mockConversationClient.Verify( + c => c.DeleteActivityAsync( + "conversation-456", + "activity-123", + It.Is(u => u.ToString().TrimEnd('/') == "https://fallback-service-url.com"), + It.IsAny(), + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DeleteActivityAsync_ThrowsArgumentException_WhenConversationIdIsNull() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + TeamsBotAdapter adapter = CreateCompatBotAdapter(mockConversationClient.Object); + + ConversationReference reference = new() + { + ActivityId = "activity-123", + ServiceUrl = "https://smba.trafficmanager.net/teams/", + Conversation = null, // No conversation + ChannelId = "msteams" + }; + + ITurnContext turnContext = CreateMockTurnContext("https://service-url.com/"); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await adapter.DeleteActivityAsync(turnContext, reference, CancellationToken.None)); + } + + [Fact] + public async Task DeleteActivityAsync_ThrowsArgumentException_WhenActivityIdIsNull() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + TeamsBotAdapter adapter = CreateCompatBotAdapter(mockConversationClient.Object); + + ConversationReference reference = new() + { + ActivityId = null, // No activity ID + ServiceUrl = "https://smba.trafficmanager.net/teams/", + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "conversation-456" }, + ChannelId = "msteams" + }; + + ITurnContext turnContext = CreateMockTurnContext("https://service-url.com/"); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await adapter.DeleteActivityAsync(turnContext, reference, CancellationToken.None)); + } + + [Fact] + public async Task DeleteActivityAsync_ThrowsArgumentException_WhenServiceUrlIsNull() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + TeamsBotAdapter adapter = CreateCompatBotAdapter(mockConversationClient.Object); + + ConversationReference reference = new() + { + ActivityId = "activity-123", + ServiceUrl = null, // No service URL in reference + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "conversation-456" }, + ChannelId = "msteams" + }; + + ITurnContext turnContext = CreateMockTurnContext(null); // No service URL in turn context either + + // Act & Assert + await Assert.ThrowsAsync( + async () => await adapter.DeleteActivityAsync(turnContext, reference, CancellationToken.None)); + } + + [Fact] + public async Task SendActivitiesAsync_SetsServiceUrlFromTurnContext() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + mockConversationClient.Setup(c => c.SendActivityAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new SendActivityResponse { Id = "sent-123" }); + + TeamsBotAdapter adapter = CreateCompatBotAdapter(mockConversationClient.Object); + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Hello", + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "conversation-123" }, + ServiceUrl = null // ServiceUrl not set on activity + }; + + ITurnContext turnContext = CreateMockTurnContext("https://turn-context-service-url.com/"); + + // Act + ResourceResponse[] responses = await adapter.SendActivitiesAsync(turnContext, [activity], CancellationToken.None); + + // Assert + Assert.Single(responses); + Assert.Equal("sent-123", responses[0].Id); + + mockConversationClient.Verify( + c => c.SendActivityAsync( + It.Is(a => a.ServiceUrl != null && a.ServiceUrl.ToString().TrimEnd('/') == "https://turn-context-service-url.com"), + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task UpdateActivityAsync_SetsServiceUrlFromTurnContext() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + mockConversationClient.Setup(c => c.UpdateActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new UpdateActivityResponse { Id = "updated-123" }); + + TeamsBotAdapter adapter = CreateCompatBotAdapter(mockConversationClient.Object); + + Activity activity = new() + { + Type = ActivityTypes.Message, + Id = "activity-123", + Text = "Updated message", + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "conversation-123" }, + ServiceUrl = null // ServiceUrl not set on activity + }; + + ITurnContext turnContext = CreateMockTurnContext("https://turn-context-service-url.com/"); + + // Act + ResourceResponse response = await adapter.UpdateActivityAsync(turnContext, activity, CancellationToken.None); + + // Assert + Assert.Equal("updated-123", response.Id); + + mockConversationClient.Verify( + c => c.UpdateActivityAsync( + "conversation-123", + "activity-123", + It.Is(a => a.ServiceUrl != null && a.ServiceUrl.ToString().TrimEnd('/') == "https://turn-context-service-url.com"), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + private static Mock CreateMockConversationClient() + { + Mock mock = new( + new HttpClient(), + NullLogger.Instance); + + mock.Setup(c => c.DeleteActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(c => c.SendActivityAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new SendActivityResponse { Id = "default-sent-id" }); + + return mock; + } + + private static Mock CreateMockTeamsBotApplication() + { + Mock mockConversationClient = CreateMockConversationClient(); + Mock mockUserTokenClient = new( + new HttpClient(), + Mock.Of(), + NullLogger.Instance); + ApiClient mockTeamsApiClient = new( + new Uri("https://service.url"), + new HttpClient(), + mockConversationClient.Object, + mockUserTokenClient.Object, + NullLogger.Instance); + + Mock mock = new( + mockConversationClient.Object, + mockUserTokenClient.Object, + mockTeamsApiClient, + Mock.Of(), + NullLogger.Instance); + + return mock; + } + + private static TeamsBotAdapter CreateCompatBotAdapter(ConversationClient conversationClient) + { + Mock mockUserTokenClient = new( + new HttpClient(), + Mock.Of(), + NullLogger.Instance); + ApiClient mockTeamsApiClient = new( + new Uri("https://service.url"), + new HttpClient(), + conversationClient, + mockUserTokenClient.Object, + NullLogger.Instance); + TeamsBotApplication teamsBotApplication = new( + conversationClient, + mockUserTokenClient.Object, + mockTeamsApiClient, + Mock.Of(), + NullLogger.Instance); + + return new TeamsBotAdapter( + teamsBotApplication, + Mock.Of(), + NullLogger.Instance); + } + + private static TeamsBotAdapter CreateCompatBotAdapter(TeamsBotApplication teamsBotApplication) + { + return new TeamsBotAdapter( + teamsBotApplication, + Mock.Of(), + NullLogger.Instance); + } + + private static ITurnContext CreateMockTurnContext(string? serviceUrl) + { + Activity activity = new() + { + Type = ActivityTypes.Message, + Id = "turn-activity-123", + ServiceUrl = serviceUrl, + Conversation = new Microsoft.Bot.Schema.ConversationAccount { Id = "turn-conversation-123" }, + From = new ChannelAccount { Id = "user-123" }, + Recipient = new ChannelAccount { Id = "bot-123" }, + ChannelId = "msteams" + }; + + Mock mockTurnContext = new(); + mockTurnContext.Setup(t => t.Activity).Returns(activity); + + return mockTurnContext.Object; + } + } +} diff --git a/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatConversationsTests.cs b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatConversationsTests.cs new file mode 100644 index 000000000..1d6d6a209 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/CompatConversationsTests.cs @@ -0,0 +1,393 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Bot.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Moq; + +namespace Microsoft.Teams.Apps.BotBuilder.UnitTests +{ + public class CompatConversationsTests + { + private const string TestServiceUrl = "https://smba.trafficmanager.net/amer/"; + private const string TestConversationId = "test-conversation-id"; + private const string TestActivityId = "test-activity-id"; + + [Fact] + public async Task SendToConversationWithHttpMessagesAsync_SetsServiceUrlFromProperty_WhenActivityServiceUrlIsNull() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Test message" + }; + + CoreActivity? capturedActivity = null; + mockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, CancellationToken>((act, _, _) => capturedActivity = act) + .ReturnsAsync(new SendActivityResponse { Id = TestActivityId }); + + // Act + await compatConversations.SendToConversationWithHttpMessagesAsync(TestConversationId, activity); + + // Assert + Assert.NotNull(capturedActivity); + Assert.NotNull(capturedActivity.ServiceUrl); + Assert.Equal(TestServiceUrl.TrimEnd('/'), capturedActivity.ServiceUrl.ToString().TrimEnd('/')); + mockConversationClient.Verify( + c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SendToConversationWithHttpMessagesAsync_DoesNotOverrideServiceUrl_WhenActivityServiceUrlIsSet() + { + // Arrange + const string activityServiceUrl = "https://custom.service.url/"; + Mock mockConversationClient = CreateMockConversationClient(); + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Test message", + ServiceUrl = activityServiceUrl + }; + + CoreActivity? capturedActivity = null; + mockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, CancellationToken>((act, _, _) => capturedActivity = act) + .ReturnsAsync(new SendActivityResponse { Id = TestActivityId }); + + // Act + await compatConversations.SendToConversationWithHttpMessagesAsync(TestConversationId, activity); + + // Assert + Assert.NotNull(capturedActivity); + Assert.NotNull(capturedActivity.ServiceUrl); + Assert.Equal(activityServiceUrl.TrimEnd('/'), capturedActivity.ServiceUrl.ToString().TrimEnd('/')); + mockConversationClient.Verify( + c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ReplyToActivityWithHttpMessagesAsync_SetsServiceUrlFromProperty_WhenActivityServiceUrlIsNull() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Test reply" + }; + + CoreActivity? capturedActivity = null; + mockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, CancellationToken>((act, _, _) => capturedActivity = act) + .ReturnsAsync(new SendActivityResponse { Id = TestActivityId }); + + // Act + await compatConversations.ReplyToActivityWithHttpMessagesAsync(TestConversationId, TestActivityId, activity); + + // Assert + Assert.NotNull(capturedActivity); + Assert.NotNull(capturedActivity.ServiceUrl); + Assert.Equal(TestServiceUrl.TrimEnd('/'), capturedActivity.ServiceUrl.ToString().TrimEnd('/')); + Assert.Equal(TestActivityId, capturedActivity.ReplyToId); + mockConversationClient.Verify( + c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ReplyToActivityWithHttpMessagesAsync_DoesNotOverrideServiceUrl_WhenActivityServiceUrlIsSet() + { + // Arrange + const string activityServiceUrl = "https://custom.service.url/"; + Mock mockConversationClient = CreateMockConversationClient(); + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Test reply", + ServiceUrl = activityServiceUrl + }; + + CoreActivity? capturedActivity = null; + mockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, CancellationToken>((act, _, _) => capturedActivity = act) + .ReturnsAsync(new SendActivityResponse { Id = TestActivityId }); + + // Act + await compatConversations.ReplyToActivityWithHttpMessagesAsync(TestConversationId, TestActivityId, activity); + + // Assert + Assert.NotNull(capturedActivity); + Assert.NotNull(capturedActivity.ServiceUrl); + Assert.Equal(activityServiceUrl.TrimEnd('/'), capturedActivity.ServiceUrl.ToString().TrimEnd('/')); + mockConversationClient.Verify( + c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task UpdateActivityWithHttpMessagesAsync_SetsServiceUrlFromProperty_WhenActivityServiceUrlIsNull() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Updated message" + }; + + CoreActivity? capturedActivity = null; + mockConversationClient + .Setup(c => c.UpdateActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((_, _, act, _, _, _, _) => capturedActivity = act) + .ReturnsAsync(new UpdateActivityResponse { Id = TestActivityId }); + + // Act + await compatConversations.UpdateActivityWithHttpMessagesAsync(TestConversationId, TestActivityId, activity); + + // Assert + Assert.NotNull(capturedActivity); + Assert.NotNull(capturedActivity.ServiceUrl); + Assert.Equal(TestServiceUrl.TrimEnd('/'), capturedActivity.ServiceUrl.ToString().TrimEnd('/')); + mockConversationClient.Verify( + c => c.UpdateActivityAsync( + TestConversationId, + TestActivityId, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task UpdateActivityWithHttpMessagesAsync_DoesNotOverrideServiceUrl_WhenActivityServiceUrlIsSet() + { + // Arrange + const string activityServiceUrl = "https://custom.service.url/"; + Mock mockConversationClient = CreateMockConversationClient(); + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Updated message", + ServiceUrl = activityServiceUrl + }; + + CoreActivity? capturedActivity = null; + mockConversationClient + .Setup(c => c.UpdateActivityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((_, _, act, _, _, _, _) => capturedActivity = act) + .ReturnsAsync(new UpdateActivityResponse { Id = TestActivityId }); + + // Act + await compatConversations.UpdateActivityWithHttpMessagesAsync(TestConversationId, TestActivityId, activity); + + // Assert + Assert.NotNull(capturedActivity); + Assert.NotNull(capturedActivity.ServiceUrl); + Assert.Equal(activityServiceUrl.TrimEnd('/'), capturedActivity.ServiceUrl.ToString().TrimEnd('/')); + mockConversationClient.Verify( + c => c.UpdateActivityAsync( + TestConversationId, + TestActivityId, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SendToConversationWithHttpMessagesAsync_EnsuresConversationIdIsSet() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Test message" + }; + + CoreActivity? capturedActivity = null; + mockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, CancellationToken>((act, _, _) => capturedActivity = act) + .ReturnsAsync(new SendActivityResponse { Id = TestActivityId }); + + // Act + await compatConversations.SendToConversationWithHttpMessagesAsync(TestConversationId, activity); + + // Assert + Assert.NotNull(capturedActivity?.Conversation); + Assert.Equal(TestConversationId, capturedActivity.Conversation.Id); + } + + [Fact] + public async Task ReplyToActivityWithHttpMessagesAsync_SetsReplyToIdProperty() + { + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Test reply" + }; + + CoreActivity? capturedActivity = null; + mockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, CancellationToken>((act, _, _) => capturedActivity = act) + .ReturnsAsync(new SendActivityResponse { Id = TestActivityId }); + + // Act + await compatConversations.ReplyToActivityWithHttpMessagesAsync(TestConversationId, "parent-activity-id", activity); + + // Assert + Assert.NotNull(capturedActivity); + Assert.Equal("parent-activity-id", capturedActivity.ReplyToId); + Assert.NotNull(capturedActivity.Conversation); + Assert.Equal(TestConversationId, capturedActivity.Conversation.Id); + } + + [Fact] + public async Task SendToConversationWithHttpMessagesAsync_WhenSendActivityReturnsNull_ReturnsStringEmptyForId() + { + // This test verifies the fix for the OAuth card null reference bug + // When APX returns 202 Accepted with no body, SendActivityAsync returns null + // We should return string.Empty for Id instead of null to maintain API contract + + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + mockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny())) + .ReturnsAsync((SendActivityResponse?)null); // Simulate 202 Accepted with no body + + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Test message" + }; + + // Act + var result = await compatConversations.SendToConversationWithHttpMessagesAsync(TestConversationId, activity); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Body); + Assert.Equal(string.Empty, result.Body.Id); // Should be string.Empty, not null + } + + [Fact] + public async Task ReplyToActivityWithHttpMessagesAsync_WhenSendActivityReturnsNull_ReturnsStringEmptyForId() + { + // This test verifies the fix for the OAuth card null reference bug in ReplyToActivity + // When APX returns 202 Accepted with no body, SendActivityAsync returns null + // We should return string.Empty for Id instead of null to maintain API contract + + // Arrange + Mock mockConversationClient = CreateMockConversationClient(); + mockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny?>(), It.IsAny())) + .ReturnsAsync((SendActivityResponse?)null); // Simulate 202 Accepted with no body + + CompatConversations compatConversations = new(mockConversationClient.Object) + { + ServiceUrl = TestServiceUrl + }; + + Activity activity = new() + { + Type = ActivityTypes.Message, + Text = "Test reply" + }; + + // Act + var result = await compatConversations.ReplyToActivityWithHttpMessagesAsync(TestConversationId, TestActivityId, activity); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Body); + Assert.Equal(string.Empty, result.Body.Id); // Should be string.Empty, not null + } + + private static Mock CreateMockConversationClient() + { + Mock mock = new( + Mock.Of(), + null!); + + return mock; + } + } +} diff --git a/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/Microsoft.Teams.Apps.BotBuilder.UnitTests.csproj b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/Microsoft.Teams.Apps.BotBuilder.UnitTests.csproj new file mode 100644 index 000000000..41c9b2702 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/Microsoft.Teams.Apps.BotBuilder.UnitTests.csproj @@ -0,0 +1,38 @@ + + + + + net10.0 + enable + enable + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/TestData/AdaptiveCardActivity.json b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/TestData/AdaptiveCardActivity.json new file mode 100644 index 000000000..e1a8f3b7e --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/TestData/AdaptiveCardActivity.json @@ -0,0 +1,75 @@ +{ + "type": "message", + "isTargeted": false, + "serviceUrl": "https://smba.trafficmanager.net/amer/1a2b3c4d-5e6f-4789-a0b1-c2d3e4f5a6b7/", + "channelId": "msteams", + "from": { + "id": "28:b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", + "name": "testbot-local" + }, + "conversation": { + "conversationType": "personal", + "id": "a:1AbCdEfGhIjKlMnOpQrStUvWxYz2AbCdEfGhIjKlMnOpQrStUvWxYz3AbCdEfGhIjKlMnOpQrStUvWxYz4AbCdEfGhIjKlMnOpQrStUvWxYz5AbCdEf", + "tenantId": "1a2b3c4d-5e6f-4789-a0b1-c2d3e4f5a6b7" + }, + "recipient": { + "id": "29:9xYzAbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjKlMnOpQrStUvWxYzAb", + "name": "Test User", + "aadObjectId": "7f8e9d0c-1b2a-4354-6758-9a0b1c2d3e4f" + }, + "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 Test User", + "body": [ + { + "type": "TextBlock", + "text": "Mention a user by User Principle Name: Hello Test User UPN" + }, + { + "type": "TextBlock", + "text": "Mention a user by AAD Object Id: Hello Test User AAD" + } + ], + "msteams": { + "entities": [ + { + "type": "mention", + "text": "Test User UPN", + "mentioned": { + "id": "testuser@example.onmicrosoft.com", + "name": "Test User" + } + }, + { + "type": "mention", + "text": "Test User AAD", + "mentioned": { + "id": "7f8e9d0c-1b2a-4354-6758-9a0b1c2d3e4f", + "name": "Test User" + } + } + ] + } + } + } + ], + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "Message", + "additionalType": [ + "AIGeneratedContent" + ] + } + ], + "replyToId": "f:a1b2c3d4-e5f6-4789-a0b1-c2d3e4f5a6b7" +} diff --git a/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/TestData/SuggestedActionsActivity.json b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/TestData/SuggestedActionsActivity.json new file mode 100644 index 000000000..1771dbba7 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.BotBuilder.UnitTests/TestData/SuggestedActionsActivity.json @@ -0,0 +1,241 @@ +{ + "type": "message", + "isTargeted" : false, + "serviceUrl": "https://smba.trafficmanager.net/teams", + "from": { + "id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6" + }, + "recipient": {}, + "conversation": { + "id": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + "text": "Hi there.\n\nI'm working on a status report and will share it in this chat shortly. You'll be able to make edits, and once it's ready, send it to the channel.\n\n\n\nYou can add more reviewers anytime.", + "inputHint": "acceptingInput", + "suggestedActions": { + "actions": [ + { + "type": "Action.Odsl", + "title": "Add reviewers", + "value": { + "actions": { + "odsl": { + "statements": [ + { + "name": "statusReportConfiguration", + "arguments": [ + { + "name": "agentId", + "value": "" + }, + { + "name": "agentName", + "value": "PA Test" + }, + { + "name": "agentType", + "value": 0 + }, + { + "name": "teamId", + "value": "" + }, + { + "name": "channelId", + "value": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + { + "name": "recurrence", + "value": { + "pattern": { + "patternType": "test" + }, + "range": { + "startDate": "test", + "endDate": "test" + } + } + }, + { + "name": "approvalList", + "value": [ + "123", + "345" + ] + }, + { + "name": "welcomeMessageType", + "value": "ApproversChat" + }, + { + "name": "chatId", + "value": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + { + "name": "displayText", + "value": "Add reviewers" + }, + { + "name": "deleteAgentDisplayText", + "value": "" + } + ] + } + ] + } + }, + "entities": [ + "chat" + ] + } + }, + { + "type": "Action.Odsl", + "title": "Open agent settings", + "value": { + "actions": { + "odsl": { + "statements": [ + { + "name": "agentConfiguration", + "arguments": [ + { + "name": "agentId", + "value": "" + }, + { + "name": "agentName", + "value": "PA Test" + }, + { + "name": "agentType", + "value": 0 + }, + { + "name": "teamId", + "value": "" + }, + { + "name": "channelId", + "value": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + { + "name": "recurrence", + "value": { + "pattern": { + "patternType": "test" + }, + "range": { + "startDate": "test", + "endDate": "test" + } + } + }, + { + "name": "approvalList", + "value": [ + "123", + "345" + ] + }, + { + "name": "welcomeMessageType", + "value": "ApproversChat" + }, + { + "name": "chatId", + "value": "19:xYz9pQrS8tUv5wXy3zAbCdEfGhIjKlMnOpQrStUvWx-Y2@thread.tacv2" + }, + { + "name": "displayText", + "value": "Open agent settings" + }, + { + "name": "deleteAgentDisplayText", + "value": "" + } + ] + } + ] + } + }, + "entities": [ + "chat" + ] + } + }, + { + "type": "Action.Compose", + "title": "Ask me a question", + "value": { + "type": "Teams.chatMessage", + "data": { + "body": { + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": true + }, + "content": "PA Test" + }, + "mentions": [ + { + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": false + }, + "id": 0, + "mentioned": { + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": false + }, + "odataType": "#microsoft.graph.chatMessageMentionedIdentitySet", + "user": { + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": false + }, + "displayName": "PA Test", + "id": "28:a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6", + "appId": "" + } + }, + "mentionText": "PA Test" + } + ], + "additionalData": {}, + "backingStore": { + "returnOnlyChangedValues": false, + "initializationCompleted": true + } + } + } + } + ] + }, + "attachments": [], + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "Message", + "additionalType": [ + "AIGeneratedContent" + ] + }, + { + "type": "BotMessageMetadata", + "botTelemetryMessageType": "Welcome-AddApproverChat", + "aiMetadata": { + "botAiSkill": "{\"cv\":\"GsaulSWnUUWlHf97qANDWA.0.0\",\"reasoningActive\":false}" + } + } + ], + "channelData": { + "feedbackLoopEnabled": true + }, + "replyToId": "f7e8d9c0-b1a2-4536-9271-a8b9c0d1e2f3" +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/ActivitiesTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/ActivitiesTests.cs new file mode 100644 index 000000000..eb03d2e77 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/ActivitiesTests.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +/// +/// Tests for simple activity types. +/// +public class ActivitiesTests +{ + [Fact] + public void MessageReaction_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.MessageReaction + }; + coreActivity.Properties["reactionsAdded"] = System.Text.Json.JsonSerializer.SerializeToElement(new[] + { + new { type = "like" }, + new { type = "heart" } + }); + + MessageReactionActivity activity = MessageReactionActivity.FromActivity(coreActivity); + Assert.NotNull(activity); + Assert.Equal(TeamsActivityType.MessageReaction, activity.Type); + Assert.NotNull(activity.ReactionsAdded); + Assert.Equal(2, activity.ReactionsAdded!.Count); + } + + [Fact] + public void MessageDelete_Constructor_Default_SetsMessageDeleteType() + { + MessageDeleteActivity activity = new(); + Assert.Equal(TeamsActivityType.MessageDelete, activity.Type); + } + + [Fact] + public void MessageDelete_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.MessageDelete, + Id = "deleted-msg-id" + }; + + MessageDeleteActivity messageDelete = MessageDeleteActivity.FromActivity(coreActivity); + Assert.NotNull(messageDelete); + Assert.Equal(TeamsActivityType.MessageDelete, messageDelete.Type); + Assert.Equal("deleted-msg-id", messageDelete.Id); + } + + [Fact] + public void MessageUpdate_Constructor_Default_SetsMessageUpdateType() + { + MessageUpdateActivity activity = new(); + Assert.Equal(TeamsActivityType.MessageUpdate, activity.Type); + } + + [Fact] + public void MessageUpdate_Constructor_WithText_SetsTextAndMessageUpdateType() + { + MessageUpdateActivity activity = new("Updated text"); + Assert.Equal(TeamsActivityType.MessageUpdate, activity.Type); + Assert.Equal("Updated text", activity.Text); + } + + [Fact] + public void MessageUpdate_InheritsFromMessageActivity() + { + MessageUpdateActivity activity = new() + { + Text = "Updated", + TextFormat = TextFormats.Markdown + }; + + Assert.Equal("Updated", activity.Text); + //Assert.Equal(InputHints.AcceptingInput, activity.InputHint); + Assert.Equal(TextFormats.Markdown, activity.TextFormat); + } + + [Fact] + public void MessageUpdate_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.MessageUpdate + }; + coreActivity.Properties["text"] = "Test message"; + + MessageUpdateActivity messageUpdate = MessageUpdateActivity.FromActivity(coreActivity); + Assert.NotNull(messageUpdate); + Assert.Equal(TeamsActivityType.MessageUpdate, messageUpdate.Type); + Assert.Equal("Test message", messageUpdate.Text); + } + + [Fact] + public void ConversationUpdate_Constructor_Default_SetsConversationUpdateType() + { + ConversationUpdateActivity activity = new(); + Assert.Equal(TeamsActivityType.ConversationUpdate, activity.Type); + } + + [Fact] + public void ConversationUpdate_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.ConversationUpdate + }; + //coreActivity.Properties["topicName"] = "Converted Topic"; + + ConversationUpdateActivity activity = ConversationUpdateActivity.FromActivity(coreActivity); + Assert.NotNull(activity); + Assert.Equal(TeamsActivityType.ConversationUpdate, activity.Type); + //Assert.Equal("Converted Topic", activity.TopicName); + } + + [Fact] + public void InstallUpdate_Constructor_Default_SetsInstallationUpdateType() + { + InstallUpdateActivity activity = new(); + Assert.Equal(TeamsActivityType.InstallationUpdate, activity.Type); + } + + [Fact] + public void InstallUpdate_FromActivityConvertsCorrectly() + { + CoreActivity coreActivity = new() + { + Type = TeamsActivityType.InstallationUpdate + }; + coreActivity.Properties["action"] = "remove"; + + InstallUpdateActivity activity = InstallUpdateActivity.FromActivity(coreActivity); + Assert.NotNull(activity); + Assert.Equal(TeamsActivityType.InstallationUpdate, activity.Type); + Assert.Equal("remove", activity.Action); + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/CitationEntityDeepCopyTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/CitationEntityDeepCopyTests.cs new file mode 100644 index 000000000..3cb7fae6f --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/CitationEntityDeepCopyTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; + +namespace Microsoft.Teams.Apps.UnitTests; + +/// +/// Verifies that the CitationEntity copy constructor deep-copies CitationClaim instances +/// so that mutations on the copy do not affect the original (COPY-01 / A-015). +/// +public class CitationEntityDeepCopyTests +{ + private static CitationEntity MakeCitation() => new CitationEntity + { + OType = "Message", + OContext = "https://schema.org", + Type = "message", + Citation = + [ + new CitationClaim + { + Position = 1, + Appearance = new CitationAppearanceDocument + { + Name = "Source A", + Abstract = "Extract from Source A", + Url = new Uri("https://example.com/a"), + EncodingFormat = "text/plain" + } + } + ] + }; + + [Fact] + public void CopyConstructor_Citation_IsDeepCopied() + { + // Arrange + CitationEntity original = MakeCitation(); + + // Act – create a copy via the OMessageEntity copy constructor + CitationEntity copy = new(original); + + // Mutate the copy's first claim + copy.Citation![0].Appearance.Name = "Mutated Name"; + + // Assert – original must be unaffected + Assert.Equal("Source A", original.Citation![0].Appearance.Name); + } + + [Fact] + public void CopyConstructor_Citation_ListIsIndependent() + { + // Arrange + CitationEntity original = MakeCitation(); + CitationEntity copy = new(original); + + // Act – add an item to the copy's list + copy.Citation!.Add(new CitationClaim + { + Position = 99, + Appearance = new CitationAppearanceDocument { Name = "Extra", Abstract = "Extra abstract" } + }); + + // Assert – original list must not grow + Assert.Single(original.Citation!); + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/CitationEntityTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/CitationEntityTests.cs new file mode 100644 index 000000000..e1cfcfb10 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/CitationEntityTests.cs @@ -0,0 +1,380 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class CitationEntityTests +{ + [Fact] + public void AddCitation_CreatesEntityWithClaim() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + var citation = activity.AddCitation(1, new CitationAppearance + { + Name = "Test Document", + Abstract = "Test abstract content" + }); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + Assert.NotNull(citation.Citation); + Assert.Single(citation.Citation); + Assert.Equal(1, citation.Citation[0].Position); + Assert.Equal("Test Document", citation.Citation[0].Appearance.Name); + Assert.Equal("Test abstract content", citation.Citation[0].Appearance.Abstract); + } + + [Fact] + public void AddCitation_MultipleCitations_AccumulateOnSameEntity() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddCitation(1, new CitationAppearance + { + Name = "Document One", + Abstract = "First abstract" + }); + + var citation = activity.AddCitation(2, new CitationAppearance + { + Name = "Document Two", + Abstract = "Second abstract" + }); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.NotNull(citation.Citation); + Assert.Equal(2, citation.Citation.Count); + Assert.Equal(1, citation.Citation[0].Position); + Assert.Equal(2, citation.Citation[1].Position); + Assert.Equal("Document One", citation.Citation[0].Appearance.Name); + Assert.Equal("Document Two", citation.Citation[1].Appearance.Name); + } + + [Fact] + public void AddAIGenerated_SetsAdditionalType() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + var messageEntity = activity.AddAIGenerated(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + Assert.NotNull(messageEntity.AdditionalType); + Assert.Contains("AIGeneratedContent", messageEntity.AdditionalType); + } + + [Fact] + public void AddAIGenerated_CalledTwice_DoesNotDuplicate() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddAIGenerated(); + activity.AddAIGenerated(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + var messageEntity = activity.Entities[0] as OMessageEntity; + Assert.NotNull(messageEntity?.AdditionalType); + Assert.Single(messageEntity.AdditionalType); + } + + [Fact] + public void AddAIGenerated_ThenAddCitation_PreservesAILabel() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddAIGenerated(); + var citation = activity.AddCitation(1, new CitationAppearance + { + Name = "Test Doc", + Abstract = "Test abstract" + }); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + Assert.NotNull(citation.AdditionalType); + Assert.Contains("AIGeneratedContent", citation.AdditionalType); + Assert.NotNull(citation.Citation); + Assert.Single(citation.Citation); + } + + [Fact] + public void AddFeedback_SetsFeedbackLoopEnabled() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddFeedback(); + + Assert.NotNull(activity.ChannelData); + Assert.True(activity.ChannelData.FeedbackLoopEnabled); + } + + [Fact] + public void AddCitation_WithAllAppearanceFields_SetsCorrectly() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + var citation = activity.AddCitation(1, new CitationAppearance + { + Name = "Full Document", + Abstract = "Full abstract", + Text = "{\"type\":\"AdaptiveCard\"}", + Url = new Uri("https://example.com/doc"), + EncodingFormat = EncodingFormats.AdaptiveCard, + Icon = CitationIcon.MicrosoftWord, + Keywords = ["keyword1", "keyword2"], + UsageInfo = new SensitiveUsageEntity { Name = "Confidential" } + }); + + Assert.NotNull(citation.Citation); + var appearance = citation.Citation[0].Appearance; + Assert.Equal("Full Document", appearance.Name); + Assert.Equal("Full abstract", appearance.Abstract); + Assert.Equal("{\"type\":\"AdaptiveCard\"}", appearance.Text); + Assert.Equal(new Uri("https://example.com/doc"), appearance.Url); + Assert.Equal(EncodingFormats.AdaptiveCard, appearance.EncodingFormat); + Assert.NotNull(appearance.Image); + Assert.Equal(CitationIcon.MicrosoftWord, appearance.Image.Name); + Assert.NotNull(appearance.Keywords); + Assert.Equal(2, appearance.Keywords.Count); + Assert.NotNull(appearance.UsageInfo); + Assert.Equal("Confidential", appearance.UsageInfo.Name); + } + + [Fact] + public void CitationEntity_RoundTrip_Serialization() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddAIGenerated(); + activity.AddCitation(1, new CitationAppearance + { + Name = "Test Document", + Abstract = "Test abstract content", + Url = new Uri("https://example.com"), + Icon = CitationIcon.Pdf, + Keywords = ["test", "citation"] + }); + activity.AddFeedback(); + + string json = activity.ToJson(); + + Assert.Contains("\"citation\"", json); + Assert.Contains("Test Document", json); + Assert.Contains("Test abstract content", json); + Assert.Contains("https://example.com", json); + Assert.Contains("AIGeneratedContent", json); + Assert.Contains("Claim", json); + Assert.Contains("DigitalDocument", json); + Assert.Contains("PDF", json); + Assert.Contains("feedbackLoopEnabled", json); + } + + [Fact] + public void CitationEntity_Rebase_SurvivesRoundTrip() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + + activity.AddAIGenerated(); + activity.AddCitation(1, new CitationAppearance + { + Name = "Rebase Test Doc", + Abstract = "Rebase test abstract", + Icon = CitationIcon.MicrosoftExcel + }); + + // Verify entities are serialized correctly via the TeamsActivity JSON output + // CoreActivity no longer has Entities; they are in Properties dict and extracted by TeamsActivity + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + string activityJson = activity.ToJson(); + Assert.Contains("citation", activityJson); + Assert.Contains("Rebase Test Doc", activityJson); + Assert.Contains("Rebase test abstract", activityJson); + Assert.Contains("AIGeneratedContent", activityJson); + Assert.Contains("Microsoft Excel", activityJson); + } + + [Fact] + public void Fixture_AdaptiveCardActivity_DeserializesAIGeneratedEntity() + { + string json = """ + { + "type": "message", + "channelId": "msteams", + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "Message", + "additionalType": [ + "AIGeneratedContent" + ] + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var entity = activity.Entities[0]; + Assert.Equal("https://schema.org/Message", entity.Type); + Assert.Equal("Message", entity.OType); + + // Should deserialize as CitationEntity (since @type is "Message") + var citationEntity = entity as CitationEntity; + Assert.NotNull(citationEntity); + Assert.NotNull(citationEntity.AdditionalType); + Assert.Contains("AIGeneratedContent", citationEntity.AdditionalType); + } + + [Fact] + public void Fixture_SensitiveUsageEntity_DeserializesByOType() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "CreativeWork", + "name": "Confidential", + "description": "This is sensitive content" + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var entity = activity.Entities[0] as SensitiveUsageEntity; + Assert.NotNull(entity); + Assert.Equal("Confidential", entity.Name); + Assert.Equal("This is sensitive content", entity.Description); + } + + [Fact] + public void OMessageEntity_WithUnknownOType_DeserializesAsOMessageEntity() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "UnknownType" + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var entity = activity.Entities[0]; + Assert.IsType(entity); + Assert.Equal("UnknownType", entity.OType); + } + + [Fact] + public void Fixture_CitationEntity_DeserializesWithClaims() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "https://schema.org/Message", + "@context": "https://schema.org", + "@type": "Message", + "additionalType": ["AIGeneratedContent"], + "citation": [ + { + "@type": "Claim", + "position": 1, + "appearance": { + "@type": "DigitalDocument", + "name": "Test Document", + "abstract": "Test abstract", + "url": "https://example.com/doc", + "encodingFormat": "application/vnd.microsoft.card.adaptive" + } + } + ] + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var citationEntity = activity.Entities[0] as CitationEntity; + Assert.NotNull(citationEntity); + Assert.NotNull(citationEntity.AdditionalType); + Assert.Contains("AIGeneratedContent", citationEntity.AdditionalType); + Assert.NotNull(citationEntity.Citation); + Assert.Single(citationEntity.Citation); + Assert.Equal(1, citationEntity.Citation[0].Position); + Assert.Equal("Test Document", citationEntity.Citation[0].Appearance.Name); + Assert.Equal("Test abstract", citationEntity.Citation[0].Appearance.Abstract); + Assert.Equal(EncodingFormats.AdaptiveCard, citationEntity.Citation[0].Appearance.EncodingFormat); + } + + [Fact] + public void CitationEntity_CopyConstructor_PreservesData() + { + var original = new CitationEntity(); + original.AdditionalType = ["AIGeneratedContent"]; + original.Citation = [ + new CitationClaim + { + Position = 1, + Appearance = new CitationAppearanceDocument + { + Name = "Doc", + Abstract = "Abstract" + } + } + ]; + + var copy = new CitationEntity(original); + + Assert.NotNull(copy.AdditionalType); + Assert.Contains("AIGeneratedContent", copy.AdditionalType); + Assert.NotNull(copy.Citation); + Assert.Single(copy.Citation); + Assert.Equal(1, copy.Citation[0].Position); + Assert.Equal("Doc", copy.Citation[0].Appearance.Name); + + // Ensure it's a deep copy (modifying copy doesn't affect original) + copy.AdditionalType.Add("NewType"); + Assert.Single(original.AdditionalType); + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/InvokeActivityTest.cs b/core/test/Microsoft.Teams.Apps.UnitTests/InvokeActivityTest.cs new file mode 100644 index 000000000..b2f57c619 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/InvokeActivityTest.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class InvokeActivityTest +{ + [Fact] + public void DefaultCtor() + { + InvokeActivity ia = new(); + Assert.NotNull(ia); + Assert.Equal(TeamsActivityType.Invoke, ia.Type); + Assert.Null(ia.Name); + Assert.Null(ia.Value); + // Assert.Null(ia.Conversation); + } + + [Fact] + public void FromCoreActivityWithValue() + { + // Build from JSON so that conversation lands in Properties as a JsonElement + CoreActivity coreActivity = CoreActivity.FromJsonString(""" + { + "type": "invoke", + "value": { "key": "value" }, + "conversation": { "id": "convId" }, + "name": "testName" + } + """); + InvokeActivity ia = InvokeActivity.FromActivity(coreActivity); + Assert.NotNull(ia); + Assert.Equal(TeamsActivityType.Invoke, ia.Type); + Assert.Equal("testName", ia.Name); + Assert.NotNull(ia.Value); + Assert.Equal("convId", ia.Conversation?.Id); + Assert.Equal("value", ia.Value?["key"]?.ToString()); + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/MessageActivityTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/MessageActivityTests.cs new file mode 100644 index 000000000..8b0f8493c --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/MessageActivityTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class MessageActivityTests +{ + [Fact] + public void Constructor_Default_SetsMessageType() + { + MessageActivity activity = new(); + Assert.Equal(TeamsActivityType.Message, activity.Type); + } + + [Fact] + public void Constructor_WithText_SetsTextAndMessageType() + { + MessageActivity activity = new("Hello World"); + Assert.Equal(TeamsActivityType.Message, activity.Type); + Assert.Equal("Hello World", activity.Text); + } + + [Fact] + public void MessageActivity_FromCoreActivity_MapsAllProperties() + { + CoreActivity coreActivity = CoreActivity.FromJsonString(jsonMessageWithAllProps); + MessageActivity messageActivity = MessageActivity.FromActivity(coreActivity); + + Assert.Equal("Hello World", messageActivity.Text); + //Assert.Equal("This is a summary", messageActivity.Summary); + Assert.Equal("plain", messageActivity.TextFormat); + //Assert.Equal(InputHints.AcceptingInput, messageActivity.InputHint); + //Assert.Equal(ImportanceLevels.High, messageActivity.Importance); + //Assert.Equal(DeliveryModes.Normal, messageActivity.DeliveryMode); + Assert.Equal("carousel", messageActivity.AttachmentLayout); + //Assert.NotNull(messageActivity.Expiration); + } + + [Fact] + public void MessageActivity_Serialize_ToJson() + { + MessageActivity activity = new("Hello World") + { + // Summary = "Test summary", + TextFormat = TextFormats.Markdown, + //InputHint = InputHints.ExpectingInput, + //Importance = ImportanceLevels.Urgent, + //DeliveryMode = DeliveryModes.Notification + }; + + string json = activity.ToJson(); + + Assert.Contains("Hello World", json); + //Assert.Contains("Test summary", json); + Assert.Contains("markdown", json); + //Assert.Contains("expectingInput", json); + //Assert.Contains("urgent", json); + //Assert.Contains("notification", json); + } + + /* + [Fact] + public void MessageActivity_WithSpeak_Serialize() + { + MessageActivity activity = new("Hello") + { + Speak = "Hello World" + }; + + string json = activity.ToJson(); + Assert.Contains("\"speak\":", json); + Assert.Contains("Hello World", json); + } + + [Fact] + public void MessageActivity_WithExpiration_Serialize() + { + string expirationDate = "2026-12-31T23:59:59Z"; + MessageActivity activity = new("Expiring message") + { + Expiration = expirationDate + }; + + string json = activity.ToJson(); + Assert.Contains("2026-12-31T23:59:59Z", json); + } + */ + + + [Fact] + public void MessageActivity_Constants_TextFormats() + { + MessageActivity activity = new("Test") + { + TextFormat = TextFormats.Plain + }; + Assert.Equal("plain", activity.TextFormat); + + activity.TextFormat = TextFormats.Markdown; + Assert.Equal("markdown", activity.TextFormat); + + activity.TextFormat = TextFormats.Xml; + Assert.Equal("xml", activity.TextFormat); + } + + [Fact] + public void MessageActivity_FromCoreActivity_WithMissingProperties_HandlesGracefully() + { + CoreActivity coreActivity = new(ActivityType.Message); + MessageActivity messageActivity = MessageActivity.FromActivity(coreActivity); + + Assert.Null(messageActivity.Text); + //Assert.Null(messageActivity.Speak); + //Assert.Null(messageActivity.InputHint); + //Assert.Null(messageActivity.Summary); + Assert.Null(messageActivity.TextFormat); + Assert.Null(messageActivity.AttachmentLayout); + //Assert.Null(messageActivity.Importance); + //Assert.Null(messageActivity.DeliveryMode); + //Assert.Null(messageActivity.Expiration); + } + + [Fact] + public void MessageActivity_SerializedAsCoreActivity_IncludesText() + { + MessageActivity messageActivity = new("Hello World") + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/") + }; + + CoreActivity coreActivity = messageActivity; + string json = coreActivity.ToJson(); + + Assert.Contains("Hello World", json); + Assert.Contains("\"text\"", json); + } + + private const string jsonMessageWithAllProps = """ + { + "type": "message", + "channelId": "msteams", + "text": "Hello World", + "speak": "Hello World", + "inputHint": "acceptingInput", + "summary": "This is a summary", + "textFormat": "plain", + "attachmentLayout": "carousel", + "importance": "high", + "deliveryMode": "normal", + "expiration": "2026-12-31T23:59:59Z", + "id": "1234567890", + "timestamp": "2026-01-21T12:00:00Z", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "from": { + "id": "user-123", + "name": "Test User" + }, + "conversation": { + "id": "conversation-123" + }, + "recipient": { + "id": "bot-123", + "name": "Test Bot" + } + } + """; +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj b/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj new file mode 100644 index 000000000..f9cf3dc09 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/OAuthFlowTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/OAuthFlowTests.cs new file mode 100644 index 000000000..5e0cf2a31 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/OAuthFlowTests.cs @@ -0,0 +1,517 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.OAuth; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; +using Microsoft.Teams.Core.Schema; +using Moq; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class OAuthFlowTests +{ + private const string GraphConnection = "graph"; + private const string GitHubConnection = "github"; + private const string TestUserId = "user-1"; + private const string TestChannelId = "msteams"; + + // ==================== signin/failure scoping ==================== + + [Fact] + public async Task SignInFailure_OnlyNotifiesFlowWithPendingSignIn() + { + // Arrange + TestHarness harness = CreateHarness(GraphConnection, GitHubConnection); + bool graphFailureFired = false; + bool githubFailureFired = false; + + harness.GraphFlow!.OnSignInFailure((_, _, _) => { graphFailureFired = true; return Task.CompletedTask; }); + harness.GitHubFlow!.OnSignInFailure((_, _, _) => { githubFailureFired = true; return Task.CompletedTask; }); + + // Initiate sign-in only for Graph (sends OAuthCard -> marks pending) + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow.SignInAsync(ctx); + + // Act - simulate signin/failure invoke for the same user + Context failureCtx = CreateInvokeContext(harness, TestUserId); + SignInFailureValue failureValue = new() { Code = "tokenmissing", Message = "Token acquisition failed." }; + + // The route handler filters by HasPendingSignIn, so verify the flags + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + Assert.False(harness.GitHubFlow.HasPendingSignIn(TestUserId)); + + await harness.GraphFlow.HandleSignInFailureAsync(failureCtx, failureValue, CancellationToken.None); + + // Assert - only Graph callback fired + Assert.True(graphFailureFired); + Assert.False(githubFailureFired); + } + + [Fact] + public async Task SignInFailure_ClearsPendingSignIn() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + + // Act + Context failureCtx = CreateInvokeContext(harness, TestUserId); + await harness.GraphFlow.HandleSignInFailureAsync(failureCtx, new SignInFailureValue { Code = "invokeerror" }, CancellationToken.None); + + // Assert + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + [Fact] + public async Task TokenExchange_Success_ClearsPendingSignIn() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + + // Arrange exchange + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection }); + + SignInTokenExchangeValue exchangeValue = new() { Id = "exchange-1", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + // Act + InvokeResponse response = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + // Assert + Assert.Equal(200, response.Status); + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + [Fact] + public async Task TokenExchange_Failure_ClearsPendingSignIn() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + + // Arrange exchange failure + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "bad-token", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Unauthorized", null, System.Net.HttpStatusCode.Unauthorized)); + + SignInTokenExchangeValue exchangeValue = new() { Id = "exchange-2", ConnectionName = GraphConnection, Token = "bad-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + // Act + InvokeResponse response = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + // Assert - 401 passed through (unexpected code) + Assert.Equal(401, response.Status); + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + [Fact] + public async Task VerifyState_Success_ClearsPendingSignIn() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + + // Arrange verify state + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "123456", It.IsAny())) + .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection }); + + SignInVerifyStateValue verifyValue = new() { State = "123456" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + // Act + InvokeResponse response = await harness.GraphFlow.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + // Assert + Assert.Equal(200, response.Status); + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + // ==================== No pending sign-in for unrelated user ==================== + + [Fact] + public async Task HasPendingSignIn_FalseForDifferentUser() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + await harness.GraphFlow!.SignInAsync(ctx); + + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + Assert.False(harness.GraphFlow.HasPendingSignIn("other-user")); + } + + // ==================== Token exchange error code mapping ==================== + + [Fact] + public async Task TokenExchange_ExpectedError_Returns412WithBody() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound)); + + SignInTokenExchangeValue exchangeValue = new() { Id = "ex-1", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + Assert.Equal(412, response.Status); + Assert.NotNull(response.Body); + } + + [Fact] + public async Task TokenExchange_UnexpectedError_ReturnsOriginalStatusCode() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Forbidden", null, System.Net.HttpStatusCode.Forbidden)); + + SignInTokenExchangeValue exchangeValue = new() { Id = "ex-2", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + Assert.Equal(403, response.Status); + } + + // ==================== Token exchange deduplication ==================== + + [Fact] + public async Task TokenExchange_Duplicate_Returns200NoOp() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection }); + + SignInTokenExchangeValue exchangeValue = new() { Id = "dup-1", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + // First call + InvokeResponse first = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + Assert.Equal(200, first.Status); + + // Second call with same exchange ID + InvokeResponse second = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + Assert.Equal(200, second.Status); + + // ExchangeTokenAsync only called once + harness.MockUserTokenClient.Verify( + c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny()), + Times.Once); + } + + // ==================== verifyState error codes ==================== + + [Fact] + public async Task VerifyState_NullState_Returns404() + { + TestHarness harness = CreateHarness(GraphConnection); + + SignInVerifyStateValue verifyValue = new() { State = null }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + Assert.Equal(404, response.Status); + } + + [Fact] + public async Task VerifyState_NoToken_Returns412_WithoutFiringFailureCallback() + { + TestHarness harness = CreateHarness(GraphConnection); + bool failureFired = false; + harness.GraphFlow!.OnSignInFailure((_, _, _) => { failureFired = true; return Task.CompletedTask; }); + + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "badcode", It.IsAny())) + .ReturnsAsync((GetTokenResult?)null); + + SignInVerifyStateValue verifyValue = new() { State = "badcode" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + Assert.Equal(412, response.Status); + // No token means the code belongs to another connection — NOT a failure + Assert.False(failureFired); + } + + [Fact] + public async Task VerifyState_ExpectedError_Returns412() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "code", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Bad request", null, System.Net.HttpStatusCode.BadRequest)); + + SignInVerifyStateValue verifyValue = new() { State = "code" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + Assert.Equal(412, response.Status); + } + + [Fact] + public async Task VerifyState_UnexpectedError_ReturnsOriginalStatusCode() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "code", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Forbidden", null, System.Net.HttpStatusCode.Forbidden)); + + SignInVerifyStateValue verifyValue = new() { State = "code" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None); + + Assert.Equal(403, response.Status); + } + + // ==================== signin/failure callback receives failure details ==================== + + [Fact] + public async Task SignInFailure_CallbackReceivesFailureDetails() + { + TestHarness harness = CreateHarness(GraphConnection); + SignInFailureValue? receivedFailure = null; + + harness.GraphFlow!.OnSignInFailure((_, failure, _) => { receivedFailure = failure; return Task.CompletedTask; }); + + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + SignInFailureValue failureValue = new() { Code = "resourcematchfailed", Message = "URI mismatch" }; + + await harness.GraphFlow.HandleSignInFailureAsync(invokeCtx, failureValue, CancellationToken.None); + + Assert.NotNull(receivedFailure); + Assert.Equal("resourcematchfailed", receivedFailure.Code); + Assert.Equal("URI mismatch", receivedFailure.Message); + } + + [Fact] + public async Task TokenExchange_FailureCallback_ReceivesNullFailureValue() + { + TestHarness harness = CreateHarness(GraphConnection); + SignInFailureValue? receivedFailure = new() { Code = "sentinel" }; + bool callbackFired = false; + + harness.GraphFlow!.OnSignInFailure((_, failure, _) => + { + callbackFired = true; + receivedFailure = failure; + return Task.CompletedTask; + }); + + harness.MockUserTokenClient + .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny())) + .ThrowsAsync(new HttpRequestException("Bad request", null, System.Net.HttpStatusCode.BadRequest)); + + SignInTokenExchangeValue exchangeValue = new() { Id = "ex-fail", ConnectionName = GraphConnection, Token = "sso-token" }; + Context invokeCtx = CreateInvokeContext(harness, TestUserId); + + await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None); + + Assert.True(callbackFired); + Assert.Null(receivedFailure); + } + + // ==================== SignInAsync returns token when cached ==================== + + [Fact] + public async Task SignInAsync_WithCachedToken_ReturnsToken() + { + TestHarness harness = CreateHarness(GraphConnection); + + harness.MockUserTokenClient + .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, null, It.IsAny())) + .ReturnsAsync(new GetTokenResult { Token = "cached-token", ConnectionName = GraphConnection }); + + Context ctx = CreateMessageContext(harness, TestUserId); + string? token = await harness.GraphFlow!.SignInAsync(ctx); + + Assert.Equal("cached-token", token); + Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + [Fact] + public async Task SignInAsync_NoToken_SendsOAuthCardAndReturnsNull() + { + TestHarness harness = CreateHarness(GraphConnection); + + SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection); + SetupGetSignInResource(harness.MockUserTokenClient); + SetupSendActivity(harness); + + Context ctx = CreateMessageContext(harness, TestUserId); + string? token = await harness.GraphFlow!.SignInAsync(ctx); + + Assert.Null(token); + Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId)); + } + + // ==================== Helpers ==================== + + private sealed class TestHarness + { + public required TeamsBotApplication App { get; init; } + public required Mock MockUserTokenClient { get; init; } + public required Mock MockConversationClient { get; init; } + public OAuthFlow? GraphFlow { get; init; } + public OAuthFlow? GitHubFlow { get; init; } + } + + private static TestHarness CreateHarness(params string[] connectionNames) + { + Mock mockUserTokenClient = CreateMockUserTokenClient(); + Mock mockConversationClient = new(new HttpClient(), NullLogger.Instance); + + ApiClient apiClient = new( + new HttpClient(), + mockConversationClient.Object, + mockUserTokenClient.Object); + + TeamsBotApplication app = new( + mockConversationClient.Object, + mockUserTokenClient.Object, + apiClient, + new HttpContextAccessor(), + NullLogger.Instance, + new BotApplicationOptions { AppId = "test-app-id" }); + + OAuthFlow? graphFlow = null; + OAuthFlow? githubFlow = null; + + foreach (string name in connectionNames) + { + OAuthFlow flow = app.AddOAuthFlow(name); + if (name == GraphConnection) graphFlow = flow; + else if (name == GitHubConnection) githubFlow = flow; + } + + return new TestHarness + { + App = app, + MockUserTokenClient = mockUserTokenClient, + MockConversationClient = mockConversationClient, + GraphFlow = graphFlow, + GitHubFlow = githubFlow + }; + } + + private static Mock CreateMockUserTokenClient() + { + Mock mockConfig = new(); + return new Mock( + new HttpClient(), + mockConfig.Object, + NullLogger.Instance); + } + + private static Context CreateMessageContext(TestHarness harness, string userId) + { + MessageActivity activity = new("hello") + { + ChannelId = TestChannelId, + From = new TeamsConversationAccount { Id = userId }, + Recipient = new TeamsConversationAccount { Id = "bot-id" }, + Conversation = new TeamsConversation { Id = "conv-1" }, + ServiceUrl = new Uri("https://smba.trafficmanager.net/test/"), + }; + + return new Context(harness.App, activity); + } + + private static Context CreateInvokeContext(TestHarness harness, string userId) + { + InvokeActivity activity = new() + { + ChannelId = TestChannelId, + From = new TeamsConversationAccount { Id = userId }, + Recipient = new TeamsConversationAccount { Id = "bot-id" }, + Conversation = new TeamsConversation { Id = "conv-1" }, + ServiceUrl = new Uri("https://smba.trafficmanager.net/test/"), + }; + + return new Context(harness.App, activity); + } + + private static void SetupSilentTokenReturnsNull(Mock mock, string connectionName) + { + mock.Setup(c => c.GetTokenAsync(TestUserId, connectionName, TestChannelId, null, It.IsAny())) + .ReturnsAsync((GetTokenResult?)null); + } + + private static void SetupGetSignInResource(Mock mock) + { + mock.Setup(c => c.GetSignInResourceAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(new GetSignInResourceResult + { + SignInLink = "https://login.microsoftonline.com/test", + TokenExchangeResource = new TokenExchangeResource { Id = "tex-1", Uri = new Uri("api://test") }, + TokenPostResource = new TokenPostResource { SasUrl = new Uri("https://token.botframework.com/test") } + }); + } + + private static void SetupSendActivity(TestHarness harness) + { + harness.MockConversationClient + .Setup(c => c.SendActivityAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new SendActivityResponse { Id = "activity-1" }); + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/QuotedReplyEntityTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/QuotedReplyEntityTests.cs new file mode 100644 index 000000000..89d8a57b6 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/QuotedReplyEntityTests.cs @@ -0,0 +1,417 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +#pragma warning disable ExperimentalTeamsQuotedReplies +public class QuotedReplyEntityTests +{ + [Fact] + public void QuotedReplyEntity_HasCorrectType() + { + var entity = new QuotedReplyEntity(); + Assert.Equal("quotedReply", entity.Type); + } + + [Fact] + public void QuotedReplyEntity_SetsAndGetsQuotedReply() + { + var entity = new QuotedReplyEntity + { + QuotedReply = new QuotedReplyData + { + MessageId = "msg-123", + SenderId = "user-1", + SenderName = "Test User", + Preview = "Hello, world!", + Time = "1772050244572", + IsReplyDeleted = false, + ValidatedMessageReference = true + } + }; + + Assert.NotNull(entity.QuotedReply); + Assert.Equal("msg-123", entity.QuotedReply.MessageId); + Assert.Equal("user-1", entity.QuotedReply.SenderId); + Assert.Equal("Test User", entity.QuotedReply.SenderName); + Assert.Equal("Hello, world!", entity.QuotedReply.Preview); + Assert.Equal("1772050244572", entity.QuotedReply.Time); + Assert.False(entity.QuotedReply.IsReplyDeleted); + Assert.True(entity.QuotedReply.ValidatedMessageReference); + } + + [Fact] + public void QuotedReplyEntity_ParameterizedConstructor_SetsMessageId() + { + var entity = new QuotedReplyEntity("msg-456"); + + Assert.Equal("quotedReply", entity.Type); + Assert.NotNull(entity.QuotedReply); + Assert.Equal("msg-456", entity.QuotedReply.MessageId); + } + + [Fact] + public void QuotedReplyEntity_MinimalData() + { + var entity = new QuotedReplyEntity + { + QuotedReply = new QuotedReplyData { MessageId = "msg-1" } + }; + + Assert.NotNull(entity.QuotedReply); + Assert.Equal("msg-1", entity.QuotedReply.MessageId); + Assert.Null(entity.QuotedReply.SenderId); + Assert.Null(entity.QuotedReply.SenderName); + Assert.Null(entity.QuotedReply.Preview); + Assert.Null(entity.QuotedReply.Time); + Assert.Null(entity.QuotedReply.IsReplyDeleted); + Assert.Null(entity.QuotedReply.ValidatedMessageReference); + } + + [Fact] + public void Fixture_QuotedReplyEntity_DeserializesFromJson() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "quotedReply", + "quotedReply": { + "messageId": "1772050244572", + "senderId": "29:a6cdfb28-56f2-4912-b9c4-2181407c7dde", + "senderName": "Centralized Test Bot", + "preview": "Reply from bot.", + "time": "1772050244572", + "validatedMessageReference": true + } + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + + var entity = activity.Entities[0] as QuotedReplyEntity; + Assert.NotNull(entity); + Assert.Equal("quotedReply", entity.Type); + Assert.NotNull(entity.QuotedReply); + Assert.Equal("1772050244572", entity.QuotedReply.MessageId); + Assert.Equal("29:a6cdfb28-56f2-4912-b9c4-2181407c7dde", entity.QuotedReply.SenderId); + Assert.Equal("Centralized Test Bot", entity.QuotedReply.SenderName); + Assert.Equal("Reply from bot.", entity.QuotedReply.Preview); + Assert.Equal("1772050244572", entity.QuotedReply.Time); + Assert.True(entity.QuotedReply.ValidatedMessageReference); + } + + [Fact] + public void Fixture_QuotedReplyEntity_DeserializesMultipleQuotes() + { + string json = """ + { + "type": "message", + "text": " first reply second reply", + "entities": [ + { + "type": "quotedReply", + "quotedReply": { + "messageId": "msg-1", + "senderName": "User A", + "preview": "First message" + } + }, + { + "type": "clientInfo", + "locale": "en-us" + }, + { + "type": "quotedReply", + "quotedReply": { + "messageId": "msg-2", + "senderName": "User B", + "preview": "Second message" + } + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.Entities); + Assert.Equal(3, activity.Entities.Count); + + var quotedReplies = activity.GetQuotedMessages().ToList(); + Assert.Equal(2, quotedReplies.Count); + Assert.Equal("msg-1", quotedReplies[0].QuotedReply?.MessageId); + Assert.Equal("msg-2", quotedReplies[1].QuotedReply?.MessageId); + } + + [Fact] + public void Fixture_QuotedReplyEntity_DeletedQuote() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "quotedReply", + "quotedReply": { + "messageId": "deleted-msg-1", + "isReplyDeleted": true + } + } + ] + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + var entity = activity.Entities?[0] as QuotedReplyEntity; + Assert.NotNull(entity); + Assert.True(entity.QuotedReply?.IsReplyDeleted); + Assert.Null(entity.QuotedReply?.SenderName); + Assert.Null(entity.QuotedReply?.Preview); + } + + // Extension tests: GetQuotedMessages + + [Fact] + public void GetQuotedMessages_FiltersCorrectly() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + activity.Entities = + [ + new ClientInfoEntity { Locale = "en-us" }, + new QuotedReplyEntity { QuotedReply = new QuotedReplyData { MessageId = "msg-1" } }, + new MentionEntity(new ConversationAccount { Id = "user-1", Name = "User" }, "User"), + new QuotedReplyEntity { QuotedReply = new QuotedReplyData { MessageId = "msg-2" } } + ]; + + var quotedReplies = activity.GetQuotedMessages().ToList(); + + Assert.Equal(2, quotedReplies.Count); + Assert.Equal("msg-1", quotedReplies[0].QuotedReply?.MessageId); + Assert.Equal("msg-2", quotedReplies[1].QuotedReply?.MessageId); + } + + [Fact] + public void GetQuotedMessages_EmptyWhenNoEntities() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + activity.Entities = null; + + var quotedReplies = activity.GetQuotedMessages().ToList(); + + Assert.Empty(quotedReplies); + } + + [Fact] + public void GetQuotedMessages_EmptyWhenNoQuotedReplyEntities() + { + TeamsActivity activity = TeamsActivity.FromActivity(new CoreActivity(ActivityType.Message)); + activity.Entities = [new ClientInfoEntity { Locale = "en-us" }]; + + var quotedReplies = activity.GetQuotedMessages().ToList(); + + Assert.Empty(quotedReplies); + } + + // Extension tests: AddQuote + + [Fact] + public void AddQuote_AddsEntityAndPlaceholder() + { + MessageActivity activity = new("existing text"); + activity.AddQuote("msg-1"); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + var entity = (QuotedReplyEntity)activity.Entities[0]; + Assert.Equal("msg-1", entity.QuotedReply?.MessageId); + Assert.Equal("existing text", activity.Text); + } + + [Fact] + public void AddQuote_WithResponse_AppendsResponseText() + { + MessageActivity activity = new(); + activity.AddQuote("msg-1", "my response"); + + Assert.Equal(" my response", activity.Text); + } + + [Fact] + public void AddQuote_MultiQuoteInterleaved() + { + MessageActivity activity = new(); + activity.AddQuote("msg-1", "response to first"); + activity.AddQuote("msg-2", "response to second"); + + Assert.Equal( + " response to first response to second", + activity.Text); + Assert.Equal(2, activity.Entities!.Count); + } + + [Fact] + public void AddQuote_GroupedQuotes() + { + MessageActivity activity = new(); + activity.AddQuote("msg-1"); + activity.AddQuote("msg-2", "response to both"); + + Assert.Equal( + " response to both", + activity.Text); + } + + [Fact] + public void AddQuote_EmptyActivity() + { + MessageActivity activity = new(); + activity.AddQuote("msg-1"); + + Assert.Equal("", activity.Text); + Assert.Single(activity.Entities!); + } + + // Builder tests: WithQuote + + [Fact] + public void Builder_WithQuote_AddsEntityAndPlaceholder() + { + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithQuote("msg-1") + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + + // Check text via Properties (builder stores text there) + Assert.True(activity.Properties.TryGetValue("text", out object? text)); + Assert.Equal("", text?.ToString()); + } + + [Fact] + public void Builder_WithQuote_WithResponse() + { + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithQuote("msg-1", "my response") + .Build(); + + Assert.True(activity.Properties.TryGetValue("text", out object? text)); + Assert.Equal(" my response", text?.ToString()); + } + + [Fact] + public void AddQuote_ToJson_ContainsQuotedReplyData() + { + MessageActivity activity = new("hello"); + activity.AddQuote("msg-123", "my response"); + + string json = activity.ToJson(); + Assert.Contains("\"quotedReply\"", json); + Assert.Contains("msg-123", json); + Assert.Contains("messageId", json); + } + + // Extension tests: PrependQuote + + [Fact] + public void PrependQuote_EmptyText_SetsPlaceholderOnly() + { + MessageActivity activity = new(); + activity.PrependQuote("msg-1"); + + Assert.Equal("", activity.Text); + Assert.Single(activity.Entities!); + } + + [Fact] + public void PrependQuote_NonEmptyText_PrependsPlaceholderWithSpace() + { + MessageActivity activity = new("hello world"); + activity.PrependQuote("msg-1"); + + Assert.Equal(" hello world", activity.Text); + } + + [Fact] + public void PrependQuote_TrimsExistingText() + { + MessageActivity activity = new(" hello "); + activity.PrependQuote("msg-1"); + + Assert.Equal(" hello", activity.Text); + } + + [Fact] + public void PrependQuote_InsertsEntityAtIndexZero() + { + MessageActivity activity = new("existing"); + activity.Entities = [new ClientInfoEntity { Locale = "en-us" }]; + + activity.PrependQuote("msg-1"); + + Assert.Equal(2, activity.Entities.Count); + Assert.IsType(activity.Entities[0]); + Assert.IsType(activity.Entities[1]); + Assert.Equal("msg-1", ((QuotedReplyEntity)activity.Entities[0]).QuotedReply?.MessageId); + } + + // Escaping tests + + [Fact] + public void AddQuote_EscapesSpecialCharsInPlaceholder() + { + MessageActivity activity = new(); + activity.AddQuote("msg<\"&>1"); + + // Placeholder uses XML-escaped attribute value; entity carries raw id + Assert.Equal("", activity.Text); + var entity = (QuotedReplyEntity)activity.Entities![0]; + Assert.Equal("msg<\"&>1", entity.QuotedReply?.MessageId); + } + + [Fact] + public void Builder_WithQuote_EscapesSpecialCharsInPlaceholder() + { + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithQuote("a\"b") + .Build(); + + Assert.True(activity.Properties.TryGetValue("text", out object? text)); + Assert.Equal("", text?.ToString()); + } + + [Fact] + public void Builder_WithQuote_MultipleQuotes() + { + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithQuote("msg-1", "first response") + .WithQuote("msg-2", "second response") + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Equal(2, activity.Entities.Count); + + Assert.True(activity.Properties.TryGetValue("text", out object? text)); + Assert.Equal( + " first response second response", + text?.ToString()); + } +} +#pragma warning restore ExperimentalTeamsQuotedReplies diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/RouterTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/RouterTests.cs new file mode 100644 index 000000000..bd91e41fe --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/RouterTests.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class RouterTests +{ + private static Route MakeRoute(string name) where TActivity : TeamsActivity + => new() { Name = name, Selector = _ => true }; + + // ==================== Duplicate name ==================== + + [Fact] + public void Register_DuplicateName_Throws() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute("Message")); + + InvalidOperationException ex = Assert.Throws(() + => router.Register(MakeRoute("Message"))); + + Assert.Contains("Message", ex.Message); + } + + [Fact] + public void Register_UniqueNames_Succeeds() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute("Message/hello")); + router.Register(MakeRoute("Message/bye")); + + Assert.Equal(2, router.GetRoutes().Count); + } + + // ==================== Invoke conflict ==================== + + [Fact] + public void Register_CatchAllInvokeAfterSpecific_Throws() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.AdaptiveCardAction}")); + + InvalidOperationException ex = Assert.Throws(() + => router.Register(MakeRoute(TeamsActivityType.Invoke))); + + Assert.Contains("catch-all", ex.Message); + } + + [Fact] + public void Register_SpecificInvokeAfterCatchAll_Throws() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute(TeamsActivityType.Invoke)); + + InvalidOperationException ex = Assert.Throws(() + => router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.TaskFetch}"))); + + Assert.Contains("invoke", ex.Message); + } + + [Fact] + public void Register_MultipleCatchAllInvokes_ThrowsDuplicateName() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute(TeamsActivityType.Invoke)); + + Assert.Throws(() + => router.Register(MakeRoute(TeamsActivityType.Invoke))); + } + + [Fact] + public void Register_MultipleSpecificInvokeHandlers_Succeeds() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.AdaptiveCardAction}")); + router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.TaskFetch}")); + router.Register(MakeRoute($"{TeamsActivityType.Invoke}/{InvokeNames.TaskSubmit}")); + + Assert.Equal(3, router.GetRoutes().Count); + } + + // ==================== Non-invoke catch-all + specific is allowed ==================== + + [Fact] + public void Register_ConversationUpdateCatchAllAndSpecific_Succeeds() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute(TeamsActivityType.ConversationUpdate)); + router.Register(MakeRoute($"{TeamsActivityType.ConversationUpdate}/membersAdded")); + + Assert.Equal(2, router.GetRoutes().Count); + } + + [Fact] + public void Register_InstallUpdateCatchAllAndSpecific_Succeeds() + { + Router router = new(NullLogger.Instance); + router.Register(MakeRoute(TeamsActivityType.InstallationUpdate)); + router.Register(MakeRoute($"{TeamsActivityType.InstallationUpdate}/add")); + router.Register(MakeRoute($"{TeamsActivityType.InstallationUpdate}/remove")); + + Assert.Equal(3, router.GetRoutes().Count); + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/SuggestedActionsTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/SuggestedActionsTests.cs new file mode 100644 index 000000000..bd9c70c9a --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/SuggestedActionsTests.cs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class SuggestedActionsTests +{ + [Fact] + public void ActionTypes_Constants_HaveExpectedValues() + { + Assert.Equal("openUrl", ActionType.OpenUrl); + Assert.Equal("imBack", ActionType.IMBack); + Assert.Equal("postBack", ActionType.PostBack); + Assert.Equal("playAudio", ActionType.PlayAudio); + Assert.Equal("playVideo", ActionType.PlayVideo); + Assert.Equal("showImage", ActionType.ShowImage); + Assert.Equal("downloadFile", ActionType.DownloadFile); + Assert.Equal("signin", ActionType.SignIn); + Assert.Equal("call", ActionType.Call); + } + + [Fact] + public void SuggestedAction_DefaultConstructor_AllPropertiesNull() + { + var action = new SuggestedAction(); + + Assert.Null(action.Type); + Assert.Null(action.Title); + Assert.Null(action.Image); + Assert.Null(action.Text); + Assert.Null(action.DisplayText); + Assert.Null(action.Value); + Assert.Null(action.ChannelData); + Assert.Null(action.ImageAltText); + } + + [Fact] + public void SuggestedAction_ConvenienceConstructor_SetsTypeAndTitle() + { + var action = new SuggestedAction(ActionType.IMBack, "Say Hello"); + + Assert.Equal(ActionType.IMBack, action.Type); + Assert.Equal("Say Hello", action.Title); + } + + [Fact] + public void SuggestedActions_DefaultConstructor_EmptyCollections() + { + var suggestedActions = new SuggestedActions(); + + Assert.NotNull(suggestedActions.To); + Assert.Empty(suggestedActions.To); + Assert.NotNull(suggestedActions.Actions); + Assert.Empty(suggestedActions.Actions); + } + + [Fact] + public void SuggestedActions_AddRecipients_AddsToList() + { + var suggestedActions = new SuggestedActions(); + + suggestedActions.AddRecipients("user1", "user2"); + + Assert.Equal(2, suggestedActions.To.Count); + Assert.Contains("user1", suggestedActions.To); + Assert.Contains("user2", suggestedActions.To); + } + + [Fact] + public void SuggestedActions_AddAction_AddsToList() + { + var suggestedActions = new SuggestedActions(); + var action = new SuggestedAction(ActionType.IMBack, "Click me"); + + suggestedActions.AddAction(action); + + Assert.Single(suggestedActions.Actions); + Assert.Equal("Click me", suggestedActions.Actions[0].Title); + } + + [Fact] + public void SuggestedActions_AddActions_AddsMultiple() + { + var suggestedActions = new SuggestedActions(); + + suggestedActions.AddActions( + new SuggestedAction(ActionType.IMBack, "Option 1"), + new SuggestedAction(ActionType.IMBack, "Option 2"), + new SuggestedAction(ActionType.PostBack, "Option 3") + ); + + Assert.Equal(3, suggestedActions.Actions.Count); + } + + [Fact] + public void SuggestedActions_FluentChaining_ReturnsSameInstance() + { + var suggestedActions = new SuggestedActions(); + var action = new SuggestedAction(ActionType.IMBack, "Test"); + + var result1 = suggestedActions.AddRecipients("user1"); + var result2 = suggestedActions.AddAction(action); + var result3 = suggestedActions.AddActions(action); + + Assert.Same(suggestedActions, result1); + Assert.Same(suggestedActions, result2); + Assert.Same(suggestedActions, result3); + } + + [Fact] + public void MessageActivity_SuggestedActions_Serialize() + { + var activity = new MessageActivity("Choose an option") + { + SuggestedActions = new SuggestedActions() + }; + activity.SuggestedActions.AddRecipients("user1"); + activity.SuggestedActions.AddAction(new SuggestedAction(ActionType.IMBack, "Option 1") { Value = "opt1" }); + + string json = activity.ToJson(); + + Assert.Contains("\"suggestedActions\"", json); + Assert.Contains("\"to\"", json); + Assert.Contains("\"actions\"", json); + Assert.Contains("\"imBack\"", json); + Assert.Contains("\"Option 1\"", json); + Assert.Contains("\"opt1\"", json); + Assert.Contains("user1", json); + } + + [Fact] + public void MessageActivity_FromCoreActivity_DeserializesSuggestedActions() + { + string json = """ + { + "type": "message", + "text": "Choose an option", + "suggestedActions": { + "to": ["user1", "user2"], + "actions": [ + { + "type": "imBack", + "title": "Option 1", + "value": "option1" + }, + { + "type": "postBack", + "title": "Option 2", + "value": "option2" + } + ] + } + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + MessageActivity activity = MessageActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.SuggestedActions); + Assert.Equal(2, activity.SuggestedActions.To.Count); + Assert.Contains("user1", activity.SuggestedActions.To); + Assert.Contains("user2", activity.SuggestedActions.To); + Assert.Equal(2, activity.SuggestedActions.Actions.Count); + Assert.Equal("imBack", activity.SuggestedActions.Actions[0].Type); + Assert.Equal("Option 1", activity.SuggestedActions.Actions[0].Title); + Assert.Equal("postBack", activity.SuggestedActions.Actions[1].Type); + Assert.Equal("Option 2", activity.SuggestedActions.Actions[1].Title); + } + + [Fact] + public void MessageActivity_WithoutSuggestedActions_PropertyIsNull() + { + string json = """ + { + "type": "message", + "text": "No suggestions here" + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + MessageActivity activity = MessageActivity.FromActivity(coreActivity); + + Assert.Null(activity.SuggestedActions); + } + + [Fact] + public void MessageActivity_WithSuggestedActions_SetsProperty() + { + var suggestedActions = new SuggestedActions(); + + var activity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Choose an option") + .WithSuggestedActions(suggestedActions) + .Build(); + + Assert.NotNull(activity.SuggestedActions); + Assert.Same(suggestedActions, activity.SuggestedActions); + Assert.Empty(activity.SuggestedActions.Actions); + } + + + + [Fact] + public void MessageActivity_WithSuggestedActions() + { + var suggestedActions = new SuggestedActions() + .AddAction(new SuggestedAction(ActionType.IMBack, "Option 1") { Value = "opt1" }); + + var activity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Choose an option") + .WithSuggestedActions(suggestedActions) + .Build(); + + Assert.NotNull(activity.SuggestedActions); + Assert.Same(suggestedActions, activity.SuggestedActions); + Assert.Single(activity.SuggestedActions.Actions); + + Assert.NotNull(activity.SuggestedActions); + Assert.Empty(activity.SuggestedActions.To); + } + + [Fact] + public void MessageActivity_SuggestedActions_RoundTrip() + { + var activity = new MessageActivity("Choose"); + activity.SuggestedActions = new SuggestedActions(); + activity.SuggestedActions.AddRecipients("user1"); + activity.SuggestedActions.AddActions( + new SuggestedAction(ActionType.OpenUrl, "Open") { Value = "https://example.com" }, + new SuggestedAction(ActionType.IMBack, "Say Hi") { Value = "hi" } + ); + + string json = activity.ToJson(); + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + MessageActivity roundTripped = MessageActivity.FromActivity(coreActivity); + + Assert.NotNull(roundTripped.SuggestedActions); + Assert.Single(roundTripped.SuggestedActions.To); + Assert.Equal("user1", roundTripped.SuggestedActions.To[0]); + Assert.Equal(2, roundTripped.SuggestedActions.Actions.Count); + Assert.Equal("openUrl", roundTripped.SuggestedActions.Actions[0].Type); + Assert.Equal("Open", roundTripped.SuggestedActions.Actions[0].Title); + Assert.Equal("imBack", roundTripped.SuggestedActions.Actions[1].Type); + Assert.Equal("Say Hi", roundTripped.SuggestedActions.Actions[1].Title); + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs new file mode 100644 index 000000000..3649da2f0 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs @@ -0,0 +1,853 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class TeamsActivityBuilderTests +{ + private readonly TeamsActivityBuilder builder; + private readonly TeamsActivityBuilder messageBuilder; + public TeamsActivityBuilderTests() + { + builder = TeamsActivity.CreateBuilder(); + messageBuilder = TeamsActivity.CreateBuilder(new MessageActivity()); + } + + [Fact] + public void Constructor_DefaultConstructor_CreatesNewActivity() + { + TeamsActivity activity = TeamsActivity.CreateBuilder().Build(); + + Assert.NotNull(activity); + Assert.Null(activity.From); + Assert.Null(activity.Recipient); + Assert.Null(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() + { + TeamsActivity 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/"); + + TeamsActivity activity = builder + .WithServiceUrl(serviceUrl) + .Build(); + + Assert.Equal(serviceUrl, activity.ServiceUrl); + } + + [Fact] + public void WithChannelId_SetsChannelId() + { + TeamsActivity activity = builder + .WithChannelId("msteams") + .Build(); + + Assert.Equal("msteams", activity.ChannelId); + } + + [Fact] + public void WithType_SetsActivityType() + { + TeamsActivity activity = builder + .WithType(TeamsActivityType.Message) + .Build(); + + Assert.Equal(TeamsActivityType.Message, activity.Type); + } + + [Fact] + public void WithText_SetsTextContent() + { + TeamsActivity activity = builder + .WithText("Hello, World!") + .Build(); + + Assert.Equal("Hello, World!", activity.Properties["text"]); + } + + [Fact] + public void WithFrom_SetsSenderAccount() + { + TeamsConversationAccount? fromAccount = TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "sender-id", + Name = "Sender Name" + }); + + TeamsActivity 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 = TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "recipient-id", + Name = "Recipient Name" + }); + Assert.NotNull(recipientAccount); + TeamsActivity activity = builder + .WithRecipient(recipientAccount) + .Build(); + + Assert.Equal("recipient-id", activity.Recipient?.Id); + Assert.Equal("Recipient Name", activity.Recipient?.Name); + } + + [Fact] + public void WithConversation_SetsConversationInfo() + { + Conversation baseConversation = new("conversation-id"); + + Assert.NotNull(baseConversation); + baseConversation.Properties.Add("tenantId", "tenant-123"); + baseConversation.Properties.Add("conversationType", "channel"); + TeamsConversation? conversation = TeamsConversation.FromConversation(baseConversation); + + TeamsActivity 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" + }; + + TeamsActivity 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" + } + ]; + + TeamsActivity 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" + } + ]; + + MessageActivity activity = (MessageActivity)messageBuilder + .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" + }; + + MessageActivity activity = (MessageActivity)messageBuilder + .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" + }; + + TeamsActivity activity = builder + .AddEntity(entity) + .Build(); + + Assert.NotNull(activity.Entities); + Assert.Single(activity.Entities); + Assert.IsType(activity.Entities[0]); + } + + [Fact] + public void AddEntity_MultipleEntities_AddsAllToCollection() + { + TeamsActivity 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" + }; + + MessageActivity activity = (MessageActivity)messageBuilder + .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() + { + MessageActivity activity = (MessageActivity)messageBuilder + .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" }; + + MessageActivity activity = (MessageActivity)messageBuilder + .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" }; + + MessageActivity activity = (MessageActivity)messageBuilder + .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" + }; + + TeamsActivity 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" + }; + + TeamsActivity 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() + { + MessageActivity activity = (MessageActivity)messageBuilder + .WithType(TeamsActivityType.Message) + .WithId("activity-123") + .WithChannelId("msteams") + .WithText("Test message") + .WithServiceUrl(new Uri("https://smba.trafficmanager.net/teams/")) + .WithFrom(TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "sender-id", + Name = "Sender" + })) + .WithRecipient(TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "recipient-id", + Name = "Recipient" + })) + .WithConversation(TeamsConversation.FromConversation(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(TeamsActivityType.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(TeamsActivityType.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 = TeamsActivityType.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(TeamsActivityType.Message, modified.Type); + } + + [Fact] + public void AddMention_UpdatesBaseEntityCollection() + { + ConversationAccount account = new() + { + Id = "user-123", + Name = "Test User" + }; + + TeamsActivity activity = builder + .AddMention(account) + .Build(); + + // Entities are on TeamsActivity, not CoreActivity; verify via TeamsActivity + Assert.NotNull(activity.Entities); + Assert.NotEmpty(activity.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.Null(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() + { + MessageActivity activity = (MessageActivity)messageBuilder.Build(); + + Assert.Null(activity.Attachments); + + TeamsAttachment attachment = new() { ContentType = "text/html" }; + messageBuilder.AddAttachment(attachment); + + MessageActivity result = (MessageActivity)messageBuilder.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 = TeamsConversation.FromConversation(new Conversation()), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()), + Recipient = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()) + }; + + Assert.Throws(() => builder.WithConversationReference(sourceActivity)); + } + + [Fact] + public void WithConversationReference_WithNullServiceUrl_ThrowsArgumentNullException() + { + TeamsActivity sourceActivity = new() + { + ChannelId = "msteams", + ServiceUrl = null!, + Conversation = TeamsConversation.FromConversation(new Conversation()), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()), + Recipient = TeamsConversationAccount.FromConversationAccount(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 = TeamsConversation.FromConversation(new Conversation()), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount { Id = "user-1" }), + Recipient = TeamsConversationAccount.FromConversationAccount(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 = TeamsConversation.FromConversation(new Conversation { Id = "conv-1" }), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()), + Recipient = TeamsConversationAccount.FromConversationAccount(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 = TeamsConversation.FromConversation(new Conversation { Id = "conv-1" }), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount { Id = "user-1" }), + Recipient = TeamsConversationAccount.FromConversationAccount(new ConversationAccount()) + }; + + TeamsActivity result = builder.WithConversationReference(sourceActivity).Build(); + + Assert.NotNull(result.From); + } + + [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() + { + MessageActivity activity = (MessageActivity)messageBuilder + .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(); + Assert.NotNull(activity1.Entities); + + builder.AddEntity(new ProductInfoEntity { Id = "prod-1" }); + TeamsActivity activity2 = builder.Build(); + + Assert.Same(activity1, activity2); + Assert.NotNull(activity2.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" + }; + + Conversation conv = new() + { + Id = "conv-001", + Properties = + { + { "tenantId", "tenant-001" }, + { "conversationType", "channel" } + } + }; + + TeamsConversation? tc = TeamsConversation.FromConversation(conv); + Assert.NotNull(tc); + + MessageActivity activity = (MessageActivity)messageBuilder + .WithType(TeamsActivityType.Message) + .WithId("msg-001") + .WithServiceUrl(serviceUrl) + .WithChannelId("msteams") + .WithText("Please review this document") + .WithFrom(TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "bot-id", + Name = "Bot" + })) + .WithRecipient(TeamsConversationAccount.FromConversationAccount(new ConversationAccount + { + Id = "user-id", + Name = "User" + })) + .WithConversation(tc) + .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(TeamsActivityType.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.Apps.UnitTests/TeamsActivityTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityTests.cs new file mode 100644 index 000000000..6320f8f1d --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityTests.cs @@ -0,0 +1,519 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core.Schema; +namespace Microsoft.Teams.Apps.UnitTests; + +public class TeamsActivityTests +{ + [Fact] + public void DownCastTeamsActivity_To_CoreActivity() + { + CoreActivity activity = CoreActivity.FromJsonString(json); + Assert.NotNull(activity.Conversation); + 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); + + } + + [Fact] + public void DownCastTeamsActivity_To_CoreActivity_FromBuilder() + { + + TeamsActivity teamsActivity = TeamsActivity + .CreateBuilder() + .WithConversation(new Conversation() { Id = "19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856" }) + .Build(); + + static void AssertCid(CoreActivity a) + { + Assert.IsAssignableFrom(a); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", ((TeamsActivity)a).Conversation!.Id); + } + AssertCid(teamsActivity); + } + + [Fact] + public void DownCastTeamsActivity_To_CoreActivity_WithoutRebase() + { + TeamsActivity teamsActivity = new() + { + Conversation = new TeamsConversation() + { + Id = "19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856" + } + }; + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", teamsActivity.Conversation!.Id); + + static void AssertCid(CoreActivity a) + { + Assert.IsAssignableFrom(a); + Assert.Equal("19:6848757105754c8981c67612732d9aa7@thread.tacv2;messageid=1759881511856", ((TeamsActivity)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(TeamsActivityType.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 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_Invoke_WithValue() + { + //TeamsActivity activity = CoreActivity.FromJsonString(jsonInvoke); + TeamsActivity activity = TeamsActivity.FromActivity(CoreActivity.FromJsonString(jsonInvoke)); + InvokeActivity invokeActivity = Assert.IsType(activity); + Assert.NotNull(invokeActivity.Value); + string feedback = invokeActivity.Value?["action"]?["data"]?["feedback"]?.ToString()!; + Assert.Equal("test invokes", feedback); + } + + [Fact] + public void Serialize_Does_Not_Repeat_AAdObjectId() + { + CoreActivity coreActivity = CoreActivity.FromJsonString(""" + { + "type": "message", + "recipient": { + "id": "rec1", + "name": "recname", + "aadObjectId": "rec-aadId-1" + } + } + """); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(coreActivity); + string json = teamsActivity.ToJson(); + string[] found = json.Split("aadObjectId"); + Assert.Equal(1, found.Length - 1); // only one occurrence + } + + [Fact] + public void FromActivity_Overrides_Recipient() + { + CoreActivity coreActivity = CoreActivity.FromJsonString(""" + { + "type": "message", + "recipient": { + "id": "rec1", + "name": "recname", + "agenticUserId": "0d5eb8a3-1642-4e63-9ccc-a89aa461716c", + "agenticAppId": "3fc62d4f-b04e-4c71-878b-02a2fa395fe2", + "agenticAppBlueprintId": "24fff850-d7fb-4d32-a6e7-a1178874430e" + } + } + """); + TeamsActivity teamsActivity = TeamsActivity.FromActivity(coreActivity); + Assert.Equal("rec1", teamsActivity.Recipient?.Id); + Assert.Equal("recname", teamsActivity.Recipient?.Name); + AgenticIdentity? agenticIdentity = AgenticIdentity.FromAccount(teamsActivity.Recipient); + Assert.NotNull(agenticIdentity); + Assert.Equal("0d5eb8a3-1642-4e63-9ccc-a89aa461716c", agenticIdentity.AgenticUserId); + Assert.Equal("3fc62d4f-b04e-4c71-878b-02a2fa395fe2", agenticIdentity.AgenticAppId); + Assert.Equal("24fff850-d7fb-4d32-a6e7-a1178874430e", agenticIdentity.AgenticAppBlueprintId); + } + + [Fact] + public void MessageActivity_FromActivity_PreservesFromAndRecipient() + { + CoreActivity coreActivity = CoreActivity.FromJsonString(""" + { + "type": "message", + "text": "hello", + "from": { + "id": "user1", + "name": "User One", + "agenticAppId": "app-1" + }, + "recipient": { + "id": "bot1", + "name": "Bot One" + } + } + """); + + MessageActivity messageActivity = MessageActivity.FromActivity(coreActivity); + + Assert.Equal("hello", messageActivity.Text); + Assert.NotNull(messageActivity.From); + Assert.Equal("user1", messageActivity.From.Id); + Assert.Equal("User One", messageActivity.From.Name); + Assert.Equal("app-1", messageActivity.From.AgenticAppId); + Assert.NotNull(messageActivity.Recipient); + Assert.Equal("bot1", messageActivity.Recipient.Id); + Assert.Equal("Bot One", messageActivity.Recipient.Name); + } + + [Fact] + public void FromActivity_ReturnsDerivedType_WhenRegistered() + { + CoreActivity coreActivity = new(ActivityType.Message); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.IsType(activity); + } + + [Fact] + public void FromActivity_ReturnsBaseType_WhenNotRegistered() + { + CoreActivity coreActivity = new("unknownType"); + TeamsActivity activity = TeamsActivity.FromActivity(coreActivity); + + Assert.Equal(typeof(TeamsActivity), activity.GetType()); + Assert.Equal("unknownType", activity.Type); + } + + [Fact] + public void EmptyTeamsActivity() + { + string minActivityJson = """ + { + "type": "message" + } + """; + + TeamsActivity teamsActivity = TeamsActivity.CreateBuilder().Build(); + Assert.NotNull(teamsActivity); + string json = teamsActivity.ToJson(); + Assert.Equal(minActivityJson, json); + } + + [Fact] + public void BaseFieldsAsBaseTypes() + { + CoreActivity ca = CoreActivity.FromJsonString(""" + { + "type": "message", + "conversation": { "id": "conv1", "tenantId": "tenant-1" } + } + """); + TeamsActivity ta = TeamsActivity.FromActivity(ca); + if (ta.Conversation is not null) + { + Assert.NotNull(ta.Conversation); + Assert.Equal("conv1", ta.Conversation.Id); + Assert.Empty(ta.Conversation.Properties); + } + else + { + Assert.Fail("Conversation not set"); + } + } + + [Fact] + public void Deserialize_with_Conversation_and_Tenant() + { + string json = """ + { + "type" : "message", + "conversation": { + "id" : "conv1", + "tenantId" : "tenant-1" + } + } + """; + CoreActivity ca = CoreActivity.FromJsonString(json); + Assert.NotNull(ca); + Assert.NotNull(ca.Conversation); + Assert.Equal("conv1", ca.Conversation.Id); + string caJson = ca.ToJson(); + Assert.Contains("\"conversation\"", caJson); + Assert.Contains("\"conv1\"", caJson); + Assert.Contains("\"tenant-1\"", caJson); + TeamsActivity ta = TeamsActivity.FromActivity(ca); + Assert.NotNull(ta); + Assert.NotNull(ta.Conversation); + Assert.Equal("conv1", ta.Conversation.Id); + Assert.Equal("tenant-1", ta.Conversation.TenantId); + } + + + [Fact] + public void TeamsActivityBuilder_WithFrom_SyncsBaseProperty() + { + // Verify that From/Recipient set via builder are accessible through a CoreActivity reference + TeamsActivity incoming = TeamsActivity.FromActivity(CoreActivity.FromJsonString(json)); + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithConversationReference(incoming) + .WithText("test") + .Build(); + + // Access through CoreActivity reference (as ConversationClient would) + CoreActivity coreRef = reply; + Assert.NotNull(coreRef.From); + Assert.Equal(incoming.Recipient?.Id, coreRef.From.Id); + + // AgenticIdentity should be accessible through the base From + ConversationAccount fromWithAgentic = new() { Id = "bot1", AgenticAppId = "app-1" }; + TeamsActivity agenticReply = TeamsActivity.CreateBuilder() + .WithConversationReference(incoming) + .WithFrom(fromWithAgentic) + .Build(); + + CoreActivity agenticCoreRef = agenticReply; + Assert.NotNull(agenticCoreRef.From); + Assert.Equal("app-1", agenticCoreRef.From.AgenticAppId); + Assert.NotNull(AgenticIdentity.FromAccount(agenticCoreRef.From)); + } + + [Fact] + public void TeamsActivityBuilder_WithFrom_DoesNotProduceDuplicateFromInJson() + { + // Build a TeamsActivity with WithConversationReference which calls WithFrom + TeamsActivity incoming = TeamsActivity.FromActivity(CoreActivity.FromJsonString(json)); + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithConversationReference(incoming) + .WithText("hello") + .Build(); + + string serialized = reply.ToJson(); + + // Count occurrences of "from" key in the JSON — should appear exactly once + int fromCount = System.Text.RegularExpressions.Regex.Matches(serialized, "\"from\"\\s*:").Count; + Assert.Equal(1, fromCount); + } + + [Fact] + public void TeamsActivityBuilder_WithRecipient_DoesNotProduceDuplicateRecipientInJson() + { + TeamsActivity incoming = TeamsActivity.FromActivity(CoreActivity.FromJsonString(json)); + TeamsActivity reply = TeamsActivity.CreateBuilder() + .WithConversationReference(incoming) + .WithRecipient(incoming.From) + .WithText("hello") + .Build(); + + string serialized = reply.ToJson(); + + int recipientCount = System.Text.RegularExpressions.Regex.Matches(serialized, "\"recipient\"\\s*:").Count; + Assert.Equal(1, recipientCount); + } + + 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.Apps.UnitTests/TeamsStreamingWriterTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs new file mode 100644 index 000000000..0bca48fce --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsStreamingWriterTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class TeamsStreamingWriterTests +{ + // Fake HttpMessageHandler that captures requests and returns pre-configured responses. + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Queue _responses = new(); + public List RequestBodies { get; } = []; + public List Requests { get; } = []; + + public void EnqueueResponse(string jsonBody, HttpStatusCode statusCode = HttpStatusCode.OK) + => _responses.Enqueue(new HttpResponseMessage(statusCode) + { + Content = new StringContent(jsonBody) + }); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + RequestBodies.Add(request.Content is not null + ? await request.Content.ReadAsStringAsync(cancellationToken) + : string.Empty); + + return _responses.Count > 0 + ? _responses.Dequeue() + : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"id\":\"default-id\"}") }; + } + } + + private static TeamsActivity CreateReferenceActivity() => new() + { + Type = TeamsActivityType.Message, + ServiceUrl = new Uri("https://smba.trafficmanager.net/amer/"), + ChannelId = "msteams", + Conversation = TeamsConversation.FromConversation(new Conversation { Id = "conv-123" }), + From = TeamsConversationAccount.FromConversationAccount(new ConversationAccount { Id = "user-123", Name = "User" }), + Recipient = TeamsConversationAccount.FromConversationAccount(new ConversationAccount { Id = "bot-123", Name = "Bot" }) + }; + + private static (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) CreateWriter(TeamsActivity? reference = null) + { + FakeHttpMessageHandler handler = new(); + ConversationClient client = new(new HttpClient(handler), NullLogger.Instance); + TeamsStreamingWriter writer = new(client, reference ?? CreateReferenceActivity()); + return (writer, handler); + } + + // ── Guard conditions ────────────────────────────────────────────────────── + + [Fact] + public async Task AppendAsync_AfterFinalizeAsync_ThrowsInvalidOperationException() + { + (TeamsStreamingWriter writer, _) = CreateWriter(); + + await writer.AppendResponseAsync("Hello"); + await writer.FinalizeResponseAsync(); + + await Assert.ThrowsAsync(() => writer.AppendResponseAsync("Too late")); + } + + [Fact] + public async Task FinalizeAsync_CalledTwice_ThrowsInvalidOperationException() + { + (TeamsStreamingWriter writer, _) = CreateWriter(); + + await writer.AppendResponseAsync("Hello"); + await writer.FinalizeResponseAsync(); + + await Assert.ThrowsAsync(() => writer.FinalizeResponseAsync()); + } + + // ── Informative-first path ──────────────────────────────────────────────── + + [Fact] + public async Task SendInformativeAsync_SendsTypingActivityWithInformativeStreamType() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.SendInformativeUpdateAsync("Thinking…"); + + Assert.Single(handler.RequestBodies); + string body = handler.RequestBodies[0]; + Assert.Contains("\"type\": \"typing\"", body); + Assert.Contains("\"streamType\": \"informative\"", body); + Assert.Contains("\"streamSequence\": 1", body); + Assert.Contains("Thinking", body); + } + + [Fact] + public async Task AppendAsync_AfterSendInformativeAsync_SendsAccumulatedText() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.SendInformativeUpdateAsync("Hello"); + await writer.AppendResponseAsync("World"); + + Assert.Equal(2, handler.RequestBodies.Count); + string body = handler.RequestBodies[1]; + Assert.Contains("\"type\": \"typing\"", body); + Assert.Contains("\"streamType\": \"streaming\"", body); + Assert.Contains("World", body); + } + + [Fact] + public async Task FinalizeAsync_SendsFullAccumulatedText() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.SendInformativeUpdateAsync("info"); + + await writer.AppendResponseAsync("Hello"); + await writer.AppendResponseAsync(", world"); + await writer.FinalizeResponseAsync(); + + string finalBody = handler.RequestBodies.Last(); + Assert.Contains("Hello, world", finalBody); + Assert.Contains("\"streamType\": \"final\"", finalBody); + } + + [Fact] + public async Task FinalizeAsync_WithNoAppendCalls_ThrowsInvalidOperationException() + { + (TeamsStreamingWriter writer, _) = CreateWriter(); + + await Assert.ThrowsAsync(() => writer.FinalizeResponseAsync()); + } + + [Fact] + public async Task FinalizeAsync_AfterOnlyInformative_ThrowsInvalidOperationException() + { + (TeamsStreamingWriter writer, _) = CreateWriter(); + + await writer.SendInformativeUpdateAsync("Thinking…"); + + await Assert.ThrowsAsync(() => writer.FinalizeResponseAsync()); + } + + // ── Shared streamId ─────────────────────────────────────────────────────── + + [Fact] + public async Task AllChunks_ShareTheSameStreamId() + { + (TeamsStreamingWriter writer, FakeHttpMessageHandler handler) = CreateWriter(); + + await writer.SendInformativeUpdateAsync("Hello"); + await writer.AppendResponseAsync("chunk"); + await writer.FinalizeResponseAsync(); + + List streamIds = handler.RequestBodies + .Select(b => + { + int start = b.IndexOf("\"streamId\": \"", StringComparison.Ordinal); + if (start < 0) return null; + start += "\"streamId\": \"".Length; + int end = b.IndexOf('"', start); + return end > start ? b[start..end] : null; + }) + .ToList(); + + Assert.Equal(3, streamIds.Count); + Assert.Null(streamIds[0]); + Assert.NotNull(streamIds[1]); + Assert.Equal(streamIds[1], streamIds[2]); + } +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs new file mode 100644 index 000000000..18c442162 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core.Hosting; +using Microsoft.Teams.Core.Schema; +using Moq; +using Moq.Protected; + +namespace Microsoft.Teams.Core.UnitTests; + +public class BotApplicationTests +{ + [Fact] + public void Constructor_InitializesProperties() + { + ConversationClient conversationClient = CreateMockConversationClient(); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + NullLogger logger = NullLogger.Instance; + + BotApplication botApp = new(conversationClient, userTokenClient, logger, CreateOptions("test-app-id")); + Assert.NotNull(botApp); + Assert.NotNull(botApp.ConversationClient); + Assert.NotNull(botApp.UserTokenClient); + Assert.NotNull(botApp.UserTokenClient); + } + + + + [Fact] + public async Task ProcessAsync_WithNullHttpContext_ThrowsArgumentNullException() + { + BotApplication botApp = CreateBotApplication(); + + await Assert.ThrowsAsync(() => + botApp.ProcessAsync(null!)); + } + + [Fact] + public async Task ProcessAsync_WithValidActivity_ProcessesSuccessfully() + { + BotApplication botApp = CreateBotApplication(); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + activity.Properties["text"] = "Test message"; + + + 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() + { + BotApplication botApp = CreateBotApplication(); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + 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.UseMiddleware(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() + { + BotApplication botApp = CreateBotApplication(); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + + 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() + { + BotApplication botApp = CreateBotApplication(); + + Mock mockMiddleware = new(); + + ITurnMiddleware result = botApp.UseMiddleware(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); + UserTokenClient userTokenClient = CreateMockUserTokenClient(); + NullLogger logger = NullLogger.Instance; + BotApplication botApp = new(conversationClient, userTokenClient, logger); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/") + }; + + activity.Conversation = new("conv123"); + SendActivityResponse? result = await botApp.SendActivityAsync(activity); + + Assert.NotNull(result); + Assert.Contains("activity123", result.Id); + } + + [Fact] + public async Task SendActivityAsync_WithNullActivity_ThrowsArgumentNullException() + { + BotApplication botApp = CreateBotApplication(); + + await Assert.ThrowsAsync(() => + botApp.SendActivityAsync(null!)); + } + + private static BotApplicationOptions CreateOptions(string appId) => + new() { AppId = appId }; + + private static BotApplication CreateBotApplication() => + new(CreateMockConversationClient(), CreateMockUserTokenClient(), NullLogger.Instance); + + 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.Core.UnitTests/ConversationClientTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/ConversationClientTests.cs new file mode 100644 index 000000000..f371c25d8 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/ConversationClientTests.cs @@ -0,0 +1,528 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core.Schema; +using Moq; +using Moq.Protected; + +namespace Microsoft.Teams.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, + ServiceUrl = new Uri("https://test.service.url/"), + Conversation = new("conv123") + }; + + SendActivityResponse? 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_WithEmptyConversationId_ThrowsArgumentException() + { + HttpClient httpClient = new(); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/"), + Conversation = new("") + }; + + 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("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, + ServiceUrl = new Uri("https://test.service.url/"), + Conversation = new("conv123") + }; + + 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, + ServiceUrl = new Uri("https://test.service.url/"), + Conversation = new("conv123") + }; + + 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); + } + + [Fact] + public async Task SendActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/"), + Conversation = new("conv123"), + Recipient = new ConversationAccount { IsTargeted = true } + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task UpdateActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/") + }; + + await conversationClient.UpdateActivityAsync("conv123", "activity123", activity, isTargeted: true); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Put, capturedRequest.Method); + } + + [Fact] + public async Task DeleteActivityAsync_WithIsTargeted_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + await conversationClient.DeleteActivityAsync( + "conv123", + "activity123", + new Uri("https://test.service.url/"), + isTargeted: true); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Delete, capturedRequest.Method); + } + + [Fact] + public async Task DeleteActivityAsync_WithActivity_UsesIsTargetedProperty() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + CoreActivity activity = new() + { + Id = "activity123", + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/") + }; + + await conversationClient.DeleteActivityAsync("conv123", activity, isTargeted: true); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Delete, capturedRequest.Method); + } + + [Fact] + public async Task UpdateTargetedActivityAsync_AppendsQueryStringWithoutRecipient() + { + HttpRequestMessage? capturedRequest = null; + string? capturedBody = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, ct) => + { + capturedRequest = req; + capturedBody = req.Content != null ? await req.Content.ReadAsStringAsync(ct) : null; + }) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/"), + }; + + await conversationClient.UpdateTargetedActivityAsync("conv123", "activity123", activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Put, capturedRequest.Method); + Assert.NotNull(capturedBody); + Assert.DoesNotContain("isTargeted", capturedBody); + } + + [Fact] + public async Task DeleteTargetedActivityAsync_AppendsQueryString() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + await conversationClient.DeleteTargetedActivityAsync( + "conv123", + "activity123", + new Uri("https://test.service.url/")); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + Assert.Equal(HttpMethod.Delete, capturedRequest.Method); + } + + [Fact] + public async Task SendActivityAsync_WithAgentsChannel_TruncatesConversationId() + { + HttpRequestMessage? capturedRequest = null; + Mock mockHttpMessageHandler = new(); + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"id\":\"activity123\"}") + }); + + HttpClient httpClient = new(mockHttpMessageHandler.Object); + ConversationClient conversationClient = new(httpClient, NullLogger.Instance); + + string longConversationId = new('x', 150); + CoreActivity activity = new() + { + Type = ActivityType.Message, + ChannelId = "agents", + ServiceUrl = new Uri("https://test.service.url/"), + Conversation = new(longConversationId) + }; + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + string expectedTruncatedId = "acf"; + Assert.Equal($"https://test.service.url/v3/conversations/{expectedTruncatedId}/activities/", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task SendActivityAsync_WithRecipientIsTargeted_DeserializedFromJson() + { + 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); + + // Simulate a deserialized activity where isTargeted is set on recipient + string activityJson = """ + { + "type": "message", + "serviceUrl": "https://test.service.url/", + "conversation": { "id": "conv123" }, + "recipient": { "id": "user1", "isTargeted": true } + } + """; + CoreActivity activity = CoreActivity.FromJsonString(activityJson); + + await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(capturedRequest); + Assert.Contains("isTargetedActivity=true", capturedRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task SendActivityAsync_WithJsonElementFrom_ExtractsAgenticIdentity() + { + 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); + + // Simulate a deserialized activity with agentic identity properties in "from" + string activityJson = """ + { + "type": "message", + "serviceUrl": "https://test.service.url/", + "conversation": { "id": "conv123" }, + "from": { "id": "bot1", "agenticAppId": "app-123", "agenticUserId": "user-456" } + } + """; + CoreActivity activity = CoreActivity.FromJsonString(activityJson); + + await conversationClient.SendActivityAsync(activity); + + // Verify the request was made (agenticIdentity is passed to BotHttpClient via request options) + Assert.NotNull(capturedRequest); + Assert.Equal(HttpMethod.Post, capturedRequest.Method); + } + + [Fact] + public async Task SendActivityAsync_WithConversationAccountFrom_ExtractsAgenticIdentity() + { + 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); + + ConversationAccount from = new() { Id = "bot1", AgenticAppId = "app-123", AgenticUserId = "user-456" }; + + CoreActivity activity = new() + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://test.service.url/"), + Conversation = new("conv123"), + From = from + }; + + SendActivityResponse? result = await conversationClient.SendActivityAsync(activity); + + Assert.NotNull(result); + } + +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/CoreActivityBuilderTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/CoreActivityBuilderTests.cs new file mode 100644 index 000000000..fa8afa591 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/CoreActivityBuilderTests.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Core.UnitTests; + +public class CoreActivityBuilderTests +{ + [Fact] + public void Constructor_DefaultConstructor_CreatesNewActivity() + { + CoreActivityBuilder builder = new(); + CoreActivity activity = builder.Build(); + + Assert.NotNull(activity); + } + + [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 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/")) + .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()); + } + + [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 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 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 WithServiceUrl_String_SetsServiceUrl() + { + CoreActivity activity = new CoreActivityBuilder() + .WithServiceUrl("https://smba.trafficmanager.net/teams/") + .Build(); + + Assert.Equal(new Uri("https://smba.trafficmanager.net/teams/"), activity.ServiceUrl); + } + +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs new file mode 100644 index 000000000..8bdfd8350 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Teams.Core.Hosting; + +namespace Microsoft.Teams.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/") + { + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(MsalConfigurationExtensions.MsalConfigKey); + Assert.Equal(expectedClientId, msalOptions.ClientId); + Assert.Equal(expectedTenantId, msalOptions.TenantId); + Assert.Equal(expectedInstance, msalOptions.Instance); + } + + [Fact] + public void AddConversationClient_WithBotFrameworkConfig_ConfiguresClientSecret() + { + // Arrange + Dictionary configData = new() + { + ["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"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(MsalConfigurationExtensions.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"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(MsalConfigurationExtensions.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"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(MsalConfigurationExtensions.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"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(MsalConfigurationExtensions.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"); + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(MsalConfigurationExtensions.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"); + } + + // --- BotApplicationOptions (AppId) tests --- + + private static ServiceProvider BuildServiceProviderForBotApp(Dictionary configData, string? sectionName = null) + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddLogging(); + + if (sectionName is null) + services.AddBotApplication(); + else + services.AddBotApplication(sectionName); + + return services.BuildServiceProvider(); + } + + private static string GetAppId(ServiceProvider serviceProvider) => + serviceProvider.GetRequiredService().AppId; + + [Fact] + public void AddBotApplication_WithMicrosoftAppId_SetsAppIdFromMicrosoftAppId() + { + // Arrange + Dictionary configData = new() + { + ["MicrosoftAppId"] = "bf-app-id", + ["MicrosoftAppTenantId"] = "bf-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData); + + // Assert + Assert.Equal("bf-app-id", GetAppId(serviceProvider)); + } + + [Fact] + public void AddBotApplication_WithClientId_SetsAppIdFromClientId() + { + // Arrange + Dictionary configData = new() + { + ["CLIENT_ID"] = "core-client-id", + ["TENANT_ID"] = "core-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData); + + // Assert + Assert.Equal("core-client-id", GetAppId(serviceProvider)); + } + + [Fact] + public void AddBotApplication_WithAzureAdSection_SetsAppIdFromSection() + { + // Arrange + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "azuread-client-id", + ["AzureAd:TenantId"] = "azuread-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData); + + // Assert + Assert.Equal("azuread-client-id", GetAppId(serviceProvider)); + } + + [Fact] + public void AddBotApplication_WithCustomSection_SetsAppIdFromCustomSection() + { + // Arrange + Dictionary configData = new() + { + ["CustomAuth:ClientId"] = "custom-client-id", + ["CustomAuth:TenantId"] = "custom-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData, "CustomAuth"); + + // Assert + Assert.Equal("custom-client-id", GetAppId(serviceProvider)); + } + + [Fact] + public void AddBotApplication_ClientIdTakesPrecedenceOverMicrosoftAppId() + { + // Arrange — both keys present; CLIENT_ID is highest priority + Dictionary configData = new() + { + ["MicrosoftAppId"] = "bf-app-id", + ["MicrosoftAppTenantId"] = "bf-tenant-id", + ["CLIENT_ID"] = "core-client-id", + ["TENANT_ID"] = "core-tenant-id" + }; + + // Act + ServiceProvider serviceProvider = BuildServiceProviderForBotApp(configData); + + // Assert + Assert.Equal("core-client-id", GetAppId(serviceProvider)); + } +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/HttpRequestExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/HttpRequestExtensionsTests.cs new file mode 100644 index 000000000..715a14e65 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/HttpRequestExtensionsTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Teams.Core.UnitTests; + +public class HttpRequestExtensionsTests +{ + [Fact] + public void GetCorrelationVector_WithValidValue_ReturnsValue() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["MS-CV"] = "valid-correlation-vector"; + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal("valid-correlation-vector", result); + } + + [Fact] + public void GetCorrelationVector_WithNewlineCharacters_SanitizesValue() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["MS-CV"] = $"correlation{Environment.NewLine}vector{Environment.NewLine}with{Environment.NewLine}newlines"; + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal("correlationvectorwithnewlines", result); + Assert.DoesNotContain(Environment.NewLine, result); + } + + [Fact] + public void GetCorrelationVector_WithCarriageReturnCharacters_SanitizesValue() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["MS-CV"] = $"correlation{Environment.NewLine}vector{Environment.NewLine}with{Environment.NewLine}carriage{Environment.NewLine}returns"; + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal("correlationvectorwithcarriagereturns", result); + Assert.DoesNotContain(Environment.NewLine, result); + } + + [Fact] + public void GetCorrelationVector_WithCRLF_SanitizesValue() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["MS-CV"] = $"correlation{Environment.NewLine}vector{Environment.NewLine}with{Environment.NewLine}CRLF"; + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal("correlationvectorwithCRLF", result); + Assert.DoesNotContain(Environment.NewLine, result); + } + + [Fact] + public void GetCorrelationVector_WithLogForgingAttempt_PreventsInjection() + { + // Simulates a malicious attempt to inject fake log entries + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["MS-CV"] = $"legitimate-value{Environment.NewLine}FAKE_LOG_ENTRY: Unauthorized access granted"; + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal("legitimate-valueFAKE_LOG_ENTRY: Unauthorized access granted", result); + Assert.DoesNotContain(Environment.NewLine, result); + // Verify that the newline that would allow log forging is removed + } + + [Fact] + public void GetCorrelationVector_WithNullRequest_ReturnsEmptyString() + { + HttpRequest? request = null; + + string? result = request!.GetCorrelationVector(); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCorrelationVector_WithMissingHeader_ReturnsEmptyString() + { + DefaultHttpContext httpContext = new(); + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCorrelationVector_WithEmptyHeader_ReturnsEmptyString() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["MS-CV"] = string.Empty; + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCorrelationVector_WithMultipleHeaderValues_ReturnsFirstValue() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["MS-CV"] = new[] { "first-value", "second-value" }; + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal("first-value", result); + } + + [Fact] + public void GetCorrelationVector_WithNewlineInMultipleValues_SanitizesFirstValue() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["MS-CV"] = new[] { $"first{Environment.NewLine}value", "second-value" }; + + string? result = httpContext.Request.GetCorrelationVector(); + + Assert.Equal("firstvalue", result); + Assert.DoesNotContain(Environment.NewLine, result); + } +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Microsoft.Teams.Core.UnitTests.csproj b/core/test/Microsoft.Teams.Core.UnitTests/Microsoft.Teams.Core.UnitTests.csproj new file mode 100644 index 000000000..6f554c125 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Microsoft.Teams.Core.UnitTests.csproj @@ -0,0 +1,31 @@ + + + net8.0;net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Core.UnitTests/MiddlewareTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/MiddlewareTests.cs new file mode 100644 index 000000000..e5ad8c20d --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/MiddlewareTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core.Schema; +using Moq; + +namespace Microsoft.Teams.Core.UnitTests; + +public class MiddlewareTests +{ + [Fact] + public async Task BotApplication_Use_AddsMiddlewareToChain() + { + BotApplication botApp = CreateBotApplication(); + + Mock mockMiddleware = new(); + + ITurnMiddleware result = botApp.UseMiddleware(mockMiddleware.Object); + + Assert.NotNull(result); + } + + + [Fact] + public async Task Middleware_ExecutesInOrder() + { + BotApplication botApp = CreateBotApplication(); + + 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.UseMiddleware(mockMiddleware1.Object); + botApp.UseMiddleware(mockMiddleware2.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + 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() + { + BotApplication botApp = CreateBotApplication(); + + 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.UseMiddleware(mockMiddleware1.Object); + botApp.UseMiddleware(mockMiddleware2.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + 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() + { + BotApplication botApp = CreateBotApplication(); + + 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.UseMiddleware(mockMiddleware.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + await botApp.ProcessAsync(httpContext); + + // ProcessAsync creates its own timeout-based CancellationToken instead of + // forwarding the caller's token, so the middleware should receive a valid + // (non-default) token that is not yet cancelled. + Assert.NotEqual(default, receivedToken); + Assert.False(receivedToken.IsCancellationRequested); + } + + [Fact] + public async Task Middleware_ReceivesActivity() + { + BotApplication botApp = CreateBotApplication(); + + 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.UseMiddleware(mockMiddleware.Object); + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act123" + }; + + + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + + await botApp.ProcessAsync(httpContext); + + Assert.NotNull(receivedActivity); + Assert.Equal(ActivityType.Message, receivedActivity.Type); + } + + private static BotApplication CreateBotApplication() => + new(CreateMockConversationClient(), CreateMockUserTokenClient(), NullLogger.Instance); + + 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.Core.UnitTests/Schema/ActivityExtensibilityTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Schema/ActivityExtensibilityTests.cs new file mode 100644 index 000000000..9d7fbdad0 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Schema/ActivityExtensibilityTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json.Serialization; + +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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, + 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 MyChannelData? ChannelData { get; set; } + + internal static MyCustomChannelDataActivity FromActivity(CoreActivity coreActivity) + { + ChannelData? extractedChannelData = coreActivity.Properties.Extract("channelData"); + + return new MyCustomChannelDataActivity + { + Type = coreActivity.Type, + ChannelId = coreActivity.ChannelId, + Id = coreActivity.Id, + ServiceUrl = coreActivity.ServiceUrl, + ChannelData = new MyChannelData(extractedChannelData ?? new Core.Schema.ChannelData()), + Properties = coreActivity.Properties + }; + } +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Schema/CoreActivityTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Schema/CoreActivityTests.cs new file mode 100644 index 000000000..be66d90ae --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Schema/CoreActivityTests.cs @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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.Equal("1", act.From.Id); + Assert.Equal("tester", act.From.Name); + 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.True(act.Properties.ContainsKey("entities")); + Assert.IsType(act.Properties["entities"]); + var entitiesElement = (JsonElement)act.Properties["entities"]!; + Assert.Equal(JsonValueKind.Array, entitiesElement.ValueKind); + Assert.Equal(2, entitiesElement.GetArrayLength()); + + } + + + [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, + From = new ConversationAccount { Id = "user1", Properties = { { "fromCustomField", "fromCustomValue" } } }, + Recipient = new ConversationAccount { Id = "bot1", Properties = { { "recipientCustomField", "recipientCustomValue" } } }, + Properties = + { + { "customField", "customValue" }, + { "channelData", new ChannelData { Properties = { { "channelCustomField", "channelCustomValue" } } } }, + { "conversation", new Conversation { Properties = { { "conversationCustomField", "conversationCustomValue" } } } }, + } + }; + 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 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.Equal("1", act.From.Id); + Assert.Equal("tester", act.From.Name); + 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); + // Value is no longer on CoreActivity — it lands in Properties via [JsonExtensionData] + Assert.True(act.Properties.ContainsKey("value")); + var valueElement = Assert.IsType(act.Properties["value"]); + Assert.Equal("value1", valueElement.GetProperty("key1").GetString()); + Assert.Equal(2, valueElement.GetProperty("key2").GetInt32()); + } + + [Fact] + public void IsTargeted_DefaultsToNull() + { + ConversationAccount account = new(); + + Assert.Null(account.IsTargeted); + } + + [Fact] + public void IsTargeted_CanBeSetToTrue() + { + ConversationAccount account = new() + { + IsTargeted = true + }; + + Assert.True(account.IsTargeted); + } + + [Fact] + public void IsTargeted_IsSerializedToJson() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Recipient = new ConversationAccount { Id = "user-123", IsTargeted = true } + }; + + string json = activity.ToJson(); + + // IsTargeted is serialized in the recipient object + Assert.Contains("isTargeted", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void IsTargeted_DeserializedFromJson() + { + string json = """ + { + "type": "message", + "recipient": { + "id": "user-123", + "isTargeted": true + } + } + """; + + CoreActivity activity = CoreActivity.FromJsonString(json); + + Assert.NotNull(activity.Recipient); + Assert.Equal("user-123", activity.Recipient.Id); + Assert.True(activity.Recipient.IsTargeted); + } +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Schema/EntitiesTest.cs b/core/test/Microsoft.Teams.Core.UnitTests/Schema/EntitiesTest.cs new file mode 100644 index 000000000..96c0b8e82 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Schema/EntitiesTest.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.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.True(activity.Properties.ContainsKey("entities")); + JsonElement entitiesElement = Assert.IsType(activity.Properties["entities"]); + Assert.Equal(JsonValueKind.Array, entitiesElement.ValueKind); + Assert.Equal(1, entitiesElement.GetArrayLength()); + JsonElement e1 = entitiesElement[0]; + Assert.Equal("mention", e1.GetProperty("type").GetString()); + Assert.True(e1.TryGetProperty("mentioned", out JsonElement mentioned)); + Assert.True(mentioned.TryGetProperty("id", out _)); + Assert.Equal("user1", mentioned.GetProperty("id").GetString()); + Assert.Equal("User One", mentioned.GetProperty("name").GetString()); + Assert.Equal("User One", e1.GetProperty("text").GetString()); + } + + [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.Properties["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); + } + + [Fact] + public void Test_Unknown_Entity() + { + string json = """ + { + "type": "message", + "entities": [ + { + "type": "unknownEntityType", + "someProperty": "someValue" + } + ] + } + """; + CoreActivity activity = CoreActivity.FromJsonString(json); + Assert.NotNull(activity); + Assert.True(activity.Properties.ContainsKey("entities")); + JsonElement entitiesElement = Assert.IsType(activity.Properties["entities"]); + Assert.Equal(JsonValueKind.Array, entitiesElement.ValueKind); + Assert.Equal(1, entitiesElement.GetArrayLength()); + JsonElement e1 = entitiesElement[0]; + Assert.Equal("unknownEntityType", e1.GetProperty("type").GetString()); + Assert.Equal("someValue", e1.GetProperty("someProperty").GetString()); + } +} diff --git a/core/test/README.md b/core/test/README.md new file mode 100644 index 000000000..6149a0206 --- /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/msal-config-api/Program.cs b/core/test/msal-config-api/Program.cs new file mode 100644 index 000000000..0be9665dc --- /dev/null +++ b/core/test/msal-config-api/Program.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; +using Microsoft.Teams.Core.Schema; + + +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(); + +CoreActivity msgOne = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithServiceUrl(new Uri(ServiceUrl)) + .WithConversation(new(ConversationId)) + .WithFrom(new ConversationAccount { Id = FromId }) + .WithProperty("text", "Test Message") + .Build(); + +await conversationClient.SendActivityAsync(msgOne, cancellationToken: default); + +await conversationClient.SendActivityAsync(CoreActivity.CreateBuilder() + .WithConversation(new Conversation("bad conversation")) + .WithServiceUrl(new Uri(ServiceUrl)) + .WithFrom(new ConversationAccount { Id = FromId}) + .Build(), 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 000000000..de64c89b2 --- /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 000000000..dea7ee90a --- /dev/null +++ b/core/version.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.0", + "versionHeightOffset": -1, + "pathFilters": ["."], + "publicReleaseRefSpec": [ + "^refs/heads/releases/core$" + ], + "cloudBuild": { + "buildNumber": { + "enabled": true + } + } +} diff --git a/version.json b/version.json index 2d95ee2d5..6f241820d 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.0.6-preview.{height}", + "version": "2.0.7-preview.{height}", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$",