diff --git a/.gitignore b/.gitignore index f5fbb39f..6f08ab79 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ appsettings.Local.json appsettings.Development.json launchsettings.json +launchSettings.json # User-specific files *.rsuser diff --git a/Microsoft.Teams.slnx b/Microsoft.Teams.slnx index fdc47a81..cc6e76dc 100644 --- a/Microsoft.Teams.slnx +++ b/Microsoft.Teams.slnx @@ -30,6 +30,7 @@ + diff --git a/README.md b/README.md index 31acae33..da3ba90c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ dotnet samples/scenarios/middleware.cs -- --urls "http://localhost:3978" |--------|-------------| | [CoreBot](samples/CoreBot/) | Lowest-level sample using `Microsoft.Teams.Core` directly | | [CustomHosting](samples/CustomHosting/) | Custom `TeamsBotApplication` subclass and hosting | +| [SocketModeBot](samples/SocketModeBot/) | Echo bot that receives activities over Socket Mode, with no web server | ### Messaging and lifecycle diff --git a/samples/SocketModeBot/Program.cs b/samples/SocketModeBot/Program.cs new file mode 100644 index 00000000..d46f05ad --- /dev/null +++ b/samples/SocketModeBot/Program.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Hosting; +using Microsoft.Teams.Apps; + +// Socket Mode receives activities over an outbound WebSocket, so the bot runs on a +// generic host with no web server and no public messaging endpoint. +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddTeamsBotApplication(options => options.UseSocketMode(socket => +{ + // Socket Mode is only available on the canary ring for now. + socket.NegotiateBaseUrl = new Uri("https://canary.botapi.skype.com"); +})); +IHost host = builder.Build(); + +TeamsBotApplication teamsApp = host.UseTeamsBotApplication(); + +teamsApp.OnMessage(async (context, cancellationToken) => +{ + // ReplyAsync quotes the user's message above the reply. + await context.ReplyAsync($"You said: {context.Activity.Text}", cancellationToken); +}); + +host.Run(); diff --git a/samples/SocketModeBot/Properties/launchSettings.TEMPLATE.json b/samples/SocketModeBot/Properties/launchSettings.TEMPLATE.json new file mode 100644 index 00000000..49ac79b6 --- /dev/null +++ b/samples/SocketModeBot/Properties/launchSettings.TEMPLATE.json @@ -0,0 +1,15 @@ +{ + "profiles": { + "SocketModeBot": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development", + "AzureAd__TenantId": "", + "AzureAd__ClientId": "", + "AzureAd__ClientCredentials__0__SourceType": "ClientSecret", + "AzureAd__ClientCredentials__0__ClientSecret": "", + "AzureAd__Instance": "https://login.microsoftonline.com/" + } + } + } +} diff --git a/samples/SocketModeBot/README.md b/samples/SocketModeBot/README.md new file mode 100644 index 00000000..44819db6 --- /dev/null +++ b/samples/SocketModeBot/README.md @@ -0,0 +1,30 @@ +# SocketModeBot + +This sample is an echo bot that receives activities over Socket Mode instead of an HTTP messaging endpoint. The bot opens outbound WebSocket connections to the Teams service, so it needs no public URL or tunnel. + +## Prerequisites + +- Bot registered in the public cloud and installed in Teams, with Socket Mode enabled for the bot. +- Bot credentials: copy `Properties/launchSettings.TEMPLATE.json` to `Properties/launchSettings.json` (git-ignored) and fill in `AzureAd__TenantId`, `AzureAd__ClientId`, and `AzureAd__ClientCredentials__0__ClientSecret`. + +## What it shows + +- `UseSocketMode(...)` on `AddTeamsBotApplication` to receive activities over Socket Mode, with `NegotiateBaseUrl` set to the canary ring, the only ring where Socket Mode is available today. +- `$(NoWarn);ExperimentalTeamsSocketMode` in the project file, because Socket Mode is experimental. +- A generic host (`Host.CreateApplicationBuilder`) with no web server, and `UseTeamsBotApplication()` to get the app. + +## Commands / Flows + +| Flow | Behavior | +|---|---| +| send any message | Bot quotes your message and replies `You said: ` | + +## Running the Sample + +~~~bash +dotnet run --project samples/SocketModeBot/SocketModeBot.csproj +~~~ + +`dotnet run` and IDEs apply the launch profile, which sets `DOTNET_ENVIRONMENT=Development` and the credentials. + +The bot is ready when the log shows `Socket Mode ready across 3 geo(s).` (amer, emea, apac). Startup fails if any geo cannot connect within the startup timeout. diff --git a/samples/SocketModeBot/SocketModeBot.csproj b/samples/SocketModeBot/SocketModeBot.csproj new file mode 100644 index 00000000..0202a949 --- /dev/null +++ b/samples/SocketModeBot/SocketModeBot.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + $(NoWarn);ExperimentalTeamsSocketMode + + + + + + + diff --git a/samples/SocketModeBot/appsettings.json b/samples/SocketModeBot/appsettings.json new file mode 100644 index 00000000..fc3a642b --- /dev/null +++ b/samples/SocketModeBot/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + } +} diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index 741893ae..48b94293 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -17,6 +17,7 @@ A high-level framework for building Microsoft Teams bots in .NET. Built on top o - **Targeted Messages** — Send messages visible only to a selected participant in supported conversations - **Observability** — OpenTelemetry spans, metrics, and Agent 365 baggage propagation - **Fluent Configuration** — Chainable handler registration and options-based service configuration +- **Socket Mode** — Receive activities over an outbound WebSocket during development, with no public endpoint or tunnel ## Installation @@ -123,6 +124,59 @@ builder.Services.AddTeamsBotApplication(options => `IDistributedCache` implementation, such as Redis, for state that must survive process restarts or be shared across instances. +## Socket Mode + +Socket Mode receives activities over outbound WebSocket connections that the bot opens to the Teams service, +instead of an HTTP messaging endpoint, so there is no public URL or dev tunnel to expose. Only inbound delivery +changes: handlers and outbound sends work the same way. + +WebSocket is only recommended for use when developing agents. Socket Mode bots should not be submitted to +Marketplace for publishing. + +Socket Mode is experimental, and its API may change. `UseSocketMode` and `SocketModeOptions` report the +`ExperimentalTeamsSocketMode` diagnostic, which fails the build until you suppress it, for example with +`$(NoWarn);ExperimentalTeamsSocketMode` in the project file. + +Socket Mode runs on a generic host with no web server: + +```csharp +using Microsoft.Extensions.Hosting; +using Microsoft.Teams.Apps; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddTeamsBotApplication(options => options.UseSocketMode()); + +var host = builder.Build(); +var teams = host.UseTeamsBotApplication(); + +teams.OnMessage(async (context, ct) => +{ + await context.ReplyAsync($"You said: {context.Activity.Text}", ct); +}); + +host.Run(); +``` + +- **One connection per geo** — The bot connects to `amer`, `emea`, and `apac` by default. Startup waits until + every geo is ready and fails if any geo cannot connect within `StartupTimeout`. Dropped connections reconnect + automatically, and connection tokens are rotated before they expire. +- **No web server** — `UseSocketMode` is the only switch; `UseTeamsBotApplication()` is the same call for both + transports. With Socket Mode enabled it throws on a `WebApplication`, and a host that includes a web server fails + to start. Tabs, OAuth callbacks, health endpoints, and other HTTP routes are unavailable. +- **Public cloud only** — Registration fails for bots configured for another cloud. +- **Classic bot identity only** — The connection is negotiated with the bot's app ID and credentials. Agentic + identities are not supported. +- **Canary endpoint** — Socket Mode is currently available only on the canary ring. Set `NegotiateBaseUrl` to + `https://canary.botapi.skype.com` until it is enabled on the default production endpoint. +- **Rejected credentials are not retried** — If negotiation returns HTTP 401 or 403, startup fails immediately. + After startup, the affected geo stops reconnecting until the app restarts. +- **Idempotent handlers** — The service can redeliver an activity, for example after a reconnect, so handlers + should tolerate running more than once for the same activity. + +`UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change the geos, negotiate URL, and connection +timeouts. `UseSocketMode(bool)` turns Socket Mode on with the defaults, or off with `false`, which clears any earlier +configuration so the bot uses HTTP. + ## Main Types | Type | Description | diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeHostedService.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeHostedService.cs new file mode 100644 index 00000000..cae28755 --- /dev/null +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeHostedService.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Teams.Apps.SocketMode; + +/// +/// Runs the Socket Mode transport for the lifetime of the host. +/// +/// +/// Startup waits until every geo is ready, so the host does not report started while the bot cannot receive +/// activities, and a startup failure surfaces from host.Run() instead of being swallowed in the background. +/// +/// The host's service provider, used to create the transport and detect a web server. +internal sealed class SocketModeHostedService(IServiceProvider services) : IHostedService +{ + private readonly IServiceProvider _services = services ?? throw new ArgumentNullException(nameof(services)); + private SocketModeTransport? _transport; + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + // Socket Mode replaces inbound HTTP rather than running beside it, so a host that would also start a web + // server (for example a WebApplication) is rejected before any socket opens. + if (_services.GetService()?.IsService(typeof(IServer)) == true) + { + throw new InvalidOperationException(TeamsBotApplicationHostingExtensions.SocketWithWebServerMessage); + } + + // Created here rather than injected, so invalid options fail when the host starts, as in the other SDKs. + _transport = _services.GetRequiredService(); + return _transport.StartAsync(cancellationToken); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + => _transport is null ? Task.CompletedTask : _transport.StopAsync().WaitAsync(cancellationToken); +} diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs new file mode 100644 index 00000000..35d9e88a --- /dev/null +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Teams.Apps.SocketMode; + +/// +/// Configures Socket Mode, where the bot receives activities over outbound WebSocket connections instead of an +/// inbound HTTP endpoint. +/// +/// +/// Socket Mode opens one connection per geo and waits until every geo is ready before the host finishes starting. +/// It is supported only in the public cloud, and runs on a host without a web server +/// (Host.CreateApplicationBuilder). Settings are validated when the host starts. +/// Socket Mode is experimental: the API is in preview and may change, and it is recommended only for developing agents. +/// +[Experimental("ExperimentalTeamsSocketMode")] +public sealed class SocketModeOptions +{ + /// + /// Gets or sets the base URL used to negotiate each geo connection. Must use HTTPS unless it targets loopback. + /// Defaults to https://botapi.skype.com. + /// + public Uri NegotiateBaseUrl { get; set; } = new(SocketModeProtocol.DefaultNegotiateBaseUrl); + + /// + /// Gets or sets the geos to connect, one connection each. An empty string connects to the base URL without a geo + /// segment. Defaults to amer, emea, and apac. + /// + public IReadOnlyList Geos { get; set; } = SocketModeProtocol.DefaultGeos; + + /// + /// Gets or sets how long each geo has to establish its initial connection. Defaults to 30 seconds. + /// + public TimeSpan StartupTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets an explicit reconnect delay schedule; the last delay repeats. When null or empty, capped + /// exponential backoff with jitter is used. + /// + public IReadOnlyList? ReconnectDelays { get; set; } + + /// + /// Gets or sets how long a new connection waits for the service to report it ready. Defaults to 30 seconds. + /// + public TimeSpan ReadinessTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the interval between keep-alive messages sent to the service. Defaults to 15 seconds. + /// + public TimeSpan KeepAliveInterval { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// Gets or sets how long without a message from the service before the connection is considered lost. + /// Defaults to 30 seconds. + /// + public TimeSpan ServerTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Snapshots the transport-level settings, so later changes to these options do not affect a running transport. + /// Invalid values are passed through for the transport to reject when it starts. + /// + /// The transport options. + internal SocketModeTransportOptions ToTransportOptions() => new() + { + NegotiateBaseUri = NegotiateBaseUrl, + Geos = Geos is null ? null! : [.. Geos], + StartupTimeout = StartupTimeout, + ReconnectDelays = ReconnectDelays is { Count: > 0 } delays ? [.. delays] : null, + }; +} diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeServiceRegistration.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeServiceRegistration.cs new file mode 100644 index 00000000..691b06c5 --- /dev/null +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeServiceRegistration.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core.Hosting; + +namespace Microsoft.Teams.Apps.SocketMode; + +/// +/// Registers the Socket Mode transport and the hosted service that runs it. +/// +internal static class SocketModeServiceRegistration +{ + /// + /// The name of the plain HTTP client used for negotiate requests. The negotiator attaches its own bot token, so + /// this client must not carry the bot authentication handler. + /// + internal const string NegotiatorHttpClientName = "SocketModeNegotiator"; + + // Core keeps its equivalents internal, so they are restated here for the only two places Socket Mode needs them. + private const string BotFrameworkScope = "https://api.botframework.com/.default"; + private const string PublicCloudTokenIssuer = "https://api.botframework.com"; + + /// + /// Checks the cloud, then registers Socket Mode for . The options themselves are + /// validated when the host starts, where the transport and connection factory are created. + /// + /// The application that processes inbound activities. + /// The service collection. + /// The resolved bot configuration. + /// The Socket Mode options. + /// Thrown when the cloud is unsupported. +#pragma warning disable ExperimentalTeamsSocketMode // Internal wiring for the experimental options passed to UseSocketMode. + internal static void AddSocketMode(IServiceCollection services, BotConfig botConfig, SocketModeOptions options) +#pragma warning restore ExperimentalTeamsSocketMode + where TApp : TeamsBotApplication + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(botConfig); + ArgumentNullException.ThrowIfNull(options); + + EnsureSupportedCloud(botConfig); + + // The bot's HTTP authorization services depend on routing, which only a web app registers. Without it a + // generic host fails service validation (on by default in Development) even though nothing resolves them. + services.AddRouting(); + + string sectionName = botConfig.SectionName; + string clientId = botConfig.ClientId; + + // The negotiator is a long-lived singleton holding one HttpClient, so connection pooling rotates connections + // instead of the factory's handler lifetime. + services.AddHttpClient(NegotiatorHttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }) + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); + + services.TryAddSingleton(sp => + { + BotTokenProvider tokenProvider = sp.GetRequiredKeyedService(sectionName); + return new SocketModeNegotiator( + sp.GetRequiredService().CreateClient(NegotiatorHttpClientName), + cancellationToken => tokenProvider.GetAppTokenAsync(BotFrameworkScope, tenantId: null, cancellationToken)); + }); + + services.TryAddSingleton(sp => new SignalRSocketConnectionFactory( + sp.GetRequiredService(), + options.ReadinessTimeout, + options.KeepAliveInterval, + options.ServerTimeout, + sp.GetRequiredService().CreateLogger())); + + services.TryAddSingleton(sp => + { + TApp app = sp.GetRequiredService(); + return new SocketModeTransport( + options.ToTransportOptions(), + sp.GetRequiredService(), + activity => DispatchAsync(app, activity), + sp.GetRequiredService().CreateLogger(), + botKey: clientId); + }); + + services.AddHostedService(); + } + + /// + /// Runs an activity through the app's pipeline and converts its invoke response to a reply status and body. + /// + /// The application that processes the activity. + /// The inbound activity. + /// The status and body to return over the socket; 200 with no body when there is no invoke response. + internal static async Task DispatchAsync(TeamsBotApplication app, Core.Schema.CoreActivity activity) + { + // No per-activity principal: the connection itself was authenticated with the bot's own token. + InvokeResponse? response = await app.ProcessWithInvokeResponseAsync(activity, user: null, correlationVector: null).ConfigureAwait(false); + return response is null + ? new SocketDispatchResult(200) + : new SocketDispatchResult(response.Status, response.Body); + } + + private static void EnsureSupportedCloud(BotConfig botConfig) + { + string issuer = botConfig.BotTokenIssuer?.Trim().TrimEnd('/') ?? string.Empty; + if (!string.Equals(issuer, PublicCloudTokenIssuer, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Socket Mode is not supported in this cloud environment (tokenIssuer={botConfig.BotTokenIssuer}). " + + "Socket Mode currently supports only regular production clouds. Use the HTTP inbound transport instead."); + } + } +} diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index 518abf6b..252a126d 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -5,6 +5,7 @@ using System.Runtime.ExceptionServices; using Microsoft.Extensions.Logging; using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; using Microsoft.Teams.Core.Schema; namespace Microsoft.Teams.Apps.SocketMode; @@ -383,11 +384,16 @@ void IGeoSocketOwner.OnGeoReconnected(string geo) } catch (Exception exception) { - _logger.LogError( - exception, - "Socket Mode failed to process activity {ActivityType} in envelope {EnvelopeId}.", - activity.Type, - envelope.EnvelopeId); + // The bot pipeline already logged handler failures before wrapping them in BotHandlerException. + if (exception is not BotHandlerException) + { + _logger.LogError( + exception, + "Socket Mode failed to process activity {ActivityType} in envelope {EnvelopeId}.", + activity.Type, + envelope.EnvelopeId); + } + await ReportErrorAsync(exception).ConfigureAwait(false); return invoke diff --git a/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs b/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs index 773fd8bd..b16026c8 100644 --- a/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs +++ b/src/Microsoft.Teams.Apps/TeamsBotApplication.HostingExtensions.cs @@ -4,7 +4,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Teams.Apps.Clients; +using Microsoft.Teams.Apps.SocketMode; using Microsoft.Teams.Apps.State; using Microsoft.Teams.Core; using Microsoft.Teams.Core.Hosting; @@ -172,30 +174,121 @@ public static IServiceCollection AddTeamsBotApplication(this IServiceColle return app; }); + if (teamsOptions.SocketMode is { } socketMode) + { + SocketModeServiceRegistration.AddSocketMode(services, botConfig, socketMode); + } + return services; } /// - /// Configures a custom on the endpoint route builder. + /// Configures a custom to receive activities over HTTP on the endpoint route builder. /// + /// + /// Use this overload to map the messaging endpoint on a route group or other endpoint builder. To receive + /// activities over Socket Mode, enable it with and call + /// on a host without a web server. + /// /// The custom type. /// The endpoint route builder. /// The route path to listen on. Default is "api/messages". /// The configured instance. + /// Thrown when Socket Mode is enabled. public static TApp UseTeamsBotApplication(this IEndpointRouteBuilder endpoints, string routePath = "api/messages") where TApp : TeamsBotApplication - => endpoints.UseBotApplication(routePath); + => UseHttp(endpoints, routePath); /// - /// Configures the default on the endpoint route builder. + /// Configures the default to receive activities over HTTP on the endpoint route builder. /// + /// + /// Use this overload to map the messaging endpoint on a route group or other endpoint builder. To receive + /// activities over Socket Mode, enable it with and call + /// on a host without a web server. + /// /// The endpoint route builder. /// The route path to listen on. Default is "api/messages". /// The configured instance. + /// Thrown when Socket Mode is enabled. public static TeamsBotApplication UseTeamsBotApplication(this IEndpointRouteBuilder endpoints, string routePath = "api/messages") - => endpoints.UseBotApplication(routePath); + => UseHttp(endpoints, routePath); + + /// + /// Configures a custom to receive activities over HTTP on a web application. + /// + /// + /// Receives activities at . This overload exists so that a , + /// which is both an and an , binds unambiguously. Socket Mode + /// runs without a web server; build a Socket Mode bot with Host.CreateApplicationBuilder() and call + /// instead. + /// + /// The custom type. + /// The web application. + /// The route path to listen on. Default is "api/messages". + /// The configured instance. + /// Thrown when Socket Mode is enabled with + /// . + public static TApp UseTeamsBotApplication(this WebApplication app, + string routePath = "api/messages") + where TApp : TeamsBotApplication + => UseTeamsBotApplicationCore(app, routePath); + + /// + /// Configures the default to receive activities over HTTP on a web application. + /// + /// + /// Receives activities at . This overload exists so that a , + /// which is both an and an , binds unambiguously. Socket Mode + /// runs without a web server; build a Socket Mode bot with Host.CreateApplicationBuilder() and call + /// instead. + /// + /// The web application. + /// The route path to listen on. Default is "api/messages". + /// The configured instance. + /// Thrown when Socket Mode is enabled with + /// . + public static TeamsBotApplication UseTeamsBotApplication(this WebApplication app, + string routePath = "api/messages") + => UseTeamsBotApplicationCore(app, routePath); + + /// + /// Configures a custom on a host, using the transport chosen in + /// . + /// + /// + /// When Socket Mode is enabled with , the host must not + /// include a web server, such as one built with Host.CreateApplicationBuilder(). The transport starts with the + /// host, which does not finish starting until every geo is connected. Otherwise the host must also be an endpoint + /// route builder, such as a , and activities are received over HTTP at api/messages. + /// + /// The custom type. + /// The built host. + /// The registered instance. + /// Thrown when the host cannot use the chosen transport, or when the + /// application is not registered. + public static TApp UseTeamsBotApplication(this IHost host) + where TApp : TeamsBotApplication + => UseTeamsBotApplicationCore(host, "api/messages"); + + /// + /// Configures the default on a host, using the transport chosen in + /// . + /// + /// + /// When Socket Mode is enabled with , the host must not + /// include a web server, such as one built with Host.CreateApplicationBuilder(). The transport starts with the + /// host, which does not finish starting until every geo is connected. Otherwise the host must also be an endpoint + /// route builder, such as a , and activities are received over HTTP at api/messages. + /// + /// The built host. + /// The registered instance. + /// Thrown when the host cannot use the chosen transport, or when the + /// application is not registered. + public static TeamsBotApplication UseTeamsBotApplication(this IHost host) + => UseTeamsBotApplicationCore(host, "api/messages"); /// /// Configures the default . Alias for . @@ -205,7 +298,53 @@ public static TeamsBotApplication UseTeamsBotApplication(this IEndpointRouteBuil /// The configured instance. [Obsolete("UseTeams is a backward-compatibility shim for the old library and will be removed. Use UseTeamsBotApplication instead.")] public static TeamsBotApplication UseTeams(this IEndpointRouteBuilder endpoints, string routePath = "api/messages") - => endpoints.UseBotApplication(routePath); + => UseHttp(endpoints, routePath); + + // UseSocketMode is the single switch: Socket Mode needs a host without a web server, and HTTP needs one. + private static TApp UseTeamsBotApplicationCore(IHost host, string routePath) + where TApp : TeamsBotApplication + { + ArgumentNullException.ThrowIfNull(host); + + if (IsSocketModeEnabled(host.Services)) + { + if (host is IEndpointRouteBuilder) + { + throw new InvalidOperationException(SocketWithWebServerMessage); + } + + return host.Services.GetService() ?? throw new InvalidOperationException("Application not registered"); + } + + if (host is not IEndpointRouteBuilder endpoints) + { + throw new InvalidOperationException( + "Receiving activities over HTTP requires a web server. Build the bot with WebApplication.CreateBuilder(), " + + "or enable Socket Mode with UseSocketMode() in AddTeamsBotApplication."); + } + + return UseHttp(endpoints, routePath); + } + + private static TApp UseHttp(IEndpointRouteBuilder endpoints, string routePath) + where TApp : TeamsBotApplication + { + ArgumentNullException.ThrowIfNull(endpoints); + + if (IsSocketModeEnabled(endpoints.ServiceProvider)) + { + throw new InvalidOperationException(SocketWithWebServerMessage); + } + + return endpoints.UseBotApplication(routePath); + } + + private static bool IsSocketModeEnabled(IServiceProvider services) + => services.GetService()?.SocketMode is not null; + + internal const string SocketWithWebServerMessage = + "Socket Mode is enabled with UseSocketMode, and it runs without a web server. Build the bot with " + + "Host.CreateApplicationBuilder() instead of WebApplication.CreateBuilder(), then call host.UseTeamsBotApplication()."; private static void AddTeamsBotApplicationState(IServiceCollection services, Action? configure) { diff --git a/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/src/Microsoft.Teams.Apps/TeamsBotApplication.cs index 5780923c..95c0348f 100644 --- a/src/Microsoft.Teams.Apps/TeamsBotApplication.cs +++ b/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.ComponentModel; +using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Teams.Apps.Clients; @@ -24,6 +25,11 @@ public class TeamsBotApplication : BotApplication private readonly TurnStateLoader? _stateLoader; private Uri? _lastServiceUrl; + // AsyncLocal rather than an instance field: one application processes many activities concurrently, and each + // turn's handlers must reach only their own capture. The capture is a mutable object so a value set deep inside + // the pipeline is visible to ProcessWithInvokeResponseAsync after the async-local value is restored. + private static readonly AsyncLocal s_invokeResponseCapture = new(); + /// /// Gets the logger instance for this application, used by . /// @@ -223,7 +229,11 @@ public TeamsBotApplication( { InvokeResponse invokeResponse = await Router.DispatchWithReturnAsync(defaultContext, cancellationToken).ConfigureAwait(false); HttpContext? httpContext = httpContextAccessor.HttpContext; - if (httpContext is not null && invokeResponse is not null) + if (invokeResponse is not null && TryCaptureInvokeResponse(invokeResponse)) + { + logger.LogDebug("Returning invoke response with status {Status} to the transport", invokeResponse.Status); + } + else if (httpContext is not null && invokeResponse is not null) { httpContext.Response.StatusCode = invokeResponse.Status; logger.LogDebug("Sending invoke response with status {Status}", invokeResponse.Status); @@ -247,6 +257,69 @@ public TeamsBotApplication( }; logger.LogDebug("TeamsBotApplication version {Version}", Version); } + + /// + /// Processes an activity from a non-HTTP transport (such as Socket Mode) and returns its invoke response, + /// instead of writing it to the current . + /// + /// The activity to process. + /// The authenticated caller, or when the transport has no per-activity principal. + /// Optional correlation vector used for logging. + /// Reserved for the caller's cancellation. A dedicated timeout governs activity processing. + /// The invoke response produced by the turn, or when there is none (for example, a non-invoke activity). + internal async Task ProcessWithInvokeResponseAsync(CoreActivity activity, ClaimsPrincipal? user, string? correlationVector, CancellationToken cancellationToken = default) + { + InvokeResponseCapture capture = new(this); + s_invokeResponseCapture.Value = capture; + try + { + await ProcessAsync(activity, user, correlationVector, cancellationToken).ConfigureAwait(false); + } + finally + { + capture.Close(); + } + + return capture.Response; + } + + private bool TryCaptureInvokeResponse(InvokeResponse response) + { + InvokeResponseCapture? capture = s_invokeResponseCapture.Value; + return capture is not null && ReferenceEquals(capture.Owner, this) && capture.TrySet(response); + } + + private sealed class InvokeResponseCapture(TeamsBotApplication owner) + { + private readonly object _gate = new(); + private bool _closed; + + public TeamsBotApplication Owner { get; } = owner; + + public InvokeResponse? Response { get; private set; } + + public bool TrySet(InvokeResponse response) + { + lock (_gate) + { + if (_closed) + { + return false; + } + + Response = response; + return true; + } + } + + public void Close() + { + lock (_gate) + { + _closed = true; + } + } + } /// /// Acquires a Graph token for the app itself, reading with application permissions. /// diff --git a/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs b/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs index af53514a..e623f2da 100644 --- a/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs +++ b/src/Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using Microsoft.Teams.Apps.OAuth; +using Microsoft.Teams.Apps.SocketMode; using Microsoft.Teams.Apps.State; using Microsoft.Teams.Core.Hosting; @@ -58,5 +60,54 @@ public TeamsBotApplicationOptions UseState(Action? configure = return this; } + // The SDK's own Socket Mode wiring uses the experimental options; the diagnostic is for callers of UseSocketMode. +#pragma warning disable ExperimentalTeamsSocketMode + internal SocketModeOptions? SocketMode { get; private set; } +#pragma warning restore ExperimentalTeamsSocketMode + + /// + /// Enables or disables Socket Mode with the default . Socket Mode receives activities + /// over outbound WebSocket connections instead of an inbound HTTP endpoint. + /// + /// + /// The host does not finish starting until every configured geo is connected, and a startup failure stops the host. + /// Supported only in the public cloud. Build the bot with Host.CreateApplicationBuilder() (no web server) and + /// get the app with host.UseTeamsBotApplication(). Passing clears any earlier Socket + /// Mode configuration, so the bot receives activities over HTTP. Socket Mode is experimental: the API is in preview + /// and may change, and it is recommended only for developing agents. + /// + /// Whether to receive activities over Socket Mode. Default is . + /// This instance for chaining. + [Experimental("ExperimentalTeamsSocketMode")] + public TeamsBotApplicationOptions UseSocketMode(bool enabled = true) + { + SocketMode = enabled ? new SocketModeOptions() : null; + return this; + } + + /// + /// Enables Socket Mode and configures its , such as the negotiate URL, geos, and + /// connection timeouts. Socket Mode receives activities over outbound WebSocket connections instead of an inbound + /// HTTP endpoint. + /// + /// + /// The host does not finish starting until every configured geo is connected, and a startup failure stops the host. + /// Supported only in the public cloud. Build the bot with Host.CreateApplicationBuilder() (no web server) and + /// get the app with host.UseTeamsBotApplication(). Socket Mode is experimental: the API is in preview and may + /// change, and it is recommended only for developing agents. + /// + /// Delegate to configure . + /// This instance for chaining. + [Experimental("ExperimentalTeamsSocketMode")] + public TeamsBotApplicationOptions UseSocketMode(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + SocketModeOptions options = new(); + configure(options); + SocketMode = options; + return this; + } + internal sealed record OAuthFlowDescriptor(string ConnectionName, OAuthOptions Options); } diff --git a/src/Microsoft.Teams.Core/BotApplication.cs b/src/Microsoft.Teams.Core/BotApplication.cs index a3467160..d4c0adb8 100644 --- a/src/Microsoft.Teams.Core/BotApplication.cs +++ b/src/Microsoft.Teams.Core/BotApplication.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics; +using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -169,8 +170,9 @@ public BotApplication(ConversationClient conversationClient, UserTokenClient use /// /// /// - /// The request body is deserialized into a , run through the registered - /// middleware pipeline (see ), and finally dispatched to . + /// The request body is deserialized into a and handed to + /// together with the + /// request's authenticated and MS-CV correlation vector. /// /// /// A dedicated internal timeout (configurable via , @@ -182,6 +184,7 @@ public BotApplication(ConversationClient conversationClient, UserTokenClient use /// 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 the activity's service URL does not match the serviceurl claim of the authenticated caller. /// 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) { @@ -192,7 +195,42 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke CoreActivity activity = await CoreActivity.FromJsonStreamAsync(httpContext.Request.Body, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Invalid Activity"); - string? correlationVector = httpContext.Request.GetCorrelationVector(); + await ProcessAsync( + activity, + httpContext.User, + httpContext.Request.GetCorrelationVector(), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Processes an already-received bot activity, independent of the transport it arrived on. + /// + /// + /// + /// The activity is run through the registered middleware pipeline (see ) and + /// finally dispatched to . The HTTP overload + /// delegates here; other transports call this + /// overload directly. Any response to the sender (such as an invoke response) is the handler's concern, not this method's. + /// + /// + /// A dedicated internal timeout (configurable via , + /// default 5 minutes) governs processing, because streaming handlers may outlive the inbound connection. + /// When a debugger is attached the timeout is disabled. + /// + /// + /// The activity to process. Cannot be null. + /// The authenticated caller. When it carries a serviceurl claim, the claim must match + /// exactly. Pass when the transport has no per-activity principal. + /// Optional correlation vector used for logging (the HTTP transport supplies the MS-CV header). + /// Reserved for the caller's cancellation. Note: a dedicated timeout governs activity processing. + /// A task that represents the asynchronous activity processing operation. + /// Thrown if the activity's service URL does not match the serviceurl claim of . + /// Thrown if an error occurs while processing the activity, wrapping the original exception and the offending . + public virtual async Task ProcessAsync(CoreActivity activity, ClaimsPrincipal? user, string? correlationVector, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(_conversationClient); + _logger.ActivityReceived(activity.Type, activity.Id, activity.ServiceUrl, correlationVector); if (_logger.IsEnabled(LogLevel.Trace)) @@ -200,7 +238,7 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke _logger.ReceivedActivityJson(activity.ToJson()); } - string serviceUrlFromClaims = httpContext.User.Claims.FirstOrDefault(c => c.Type == "serviceurl")?.Value ?? string.Empty; + string serviceUrlFromClaims = user?.Claims.FirstOrDefault(c => c.Type == "serviceurl")?.Value ?? string.Empty; if (!string.IsNullOrEmpty(serviceUrlFromClaims) && !serviceUrlFromClaims.Equals(activity.ServiceUrl?.ToString(), StringComparison.Ordinal)) { _logger.LogServiceUrlClaimMismatch(activity.ServiceUrl, serviceUrlFromClaims); diff --git a/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj b/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj index f331822f..e739d652 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj +++ b/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj @@ -5,7 +5,7 @@ enable enable false - $(NoWarn);ExperimentalTeamsHtmlWidget;ExperimentalTeamsExtendedMarkdown + $(NoWarn);ExperimentalTeamsHtmlWidget;ExperimentalTeamsExtendedMarkdown;ExperimentalTeamsSocketMode diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs new file mode 100644 index 00000000..4cb149e3 --- /dev/null +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Abstractions; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.SocketMode; +using Microsoft.Teams.Core.Hosting; +using Moq; + +namespace Microsoft.Teams.Apps.UnitTests.SocketMode; + +public class SocketModeHostingTests +{ + private const string ClientId = "socket-client-id"; + + private static ServiceCollection CreateServices(Dictionary? extraConfig = null) + { + Dictionary config = new() + { + ["AzureAd:ClientId"] = ClientId, + ["AzureAd:TenantId"] = "socket-tenant-id", + }; + foreach (KeyValuePair entry in extraConfig ?? []) + { + config[entry.Key] = entry.Value; + } + + ServiceCollection services = new(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection(config).Build()); + services.AddLogging(); + return services; + } + + [Fact] + public void WithoutSocketMode_RegistersNoSocketServices() + { + ServiceCollection services = CreateServices(); + services.AddTeamsBotApplication(); + + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Empty(provider.GetServices().OfType()); + Assert.Null(provider.GetService()); + } + + [Fact] + public async Task WithSocketMode_RegistersTransportAndHostedService() + { + ServiceCollection services = CreateServices(); + services.AddTeamsBotApplication(options => options.UseSocketMode()); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Single(provider.GetServices().OfType()); + SocketModeTransport transport = provider.GetRequiredService(); + Assert.Equal(SocketModeStatus.Idle, transport.Status); + Assert.IsType(provider.GetRequiredService()); + } + + [Theory] + [InlineData("https://api.botframework.us")] + [InlineData("https://api.botframework.azure.cn")] + public void WithSocketMode_RejectsNonPublicCloudAtRegistration(string issuer) + { + ServiceCollection services = CreateServices(new() { ["BotFramework:BotTokenIssuer"] = issuer }); + + InvalidOperationException exception = Assert.Throws( + () => services.AddTeamsBotApplication(options => options.UseSocketMode())); + + Assert.Contains("Socket Mode is not supported in this cloud environment", exception.Message, StringComparison.Ordinal); + Assert.Contains(issuer, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void WithSocketMode_AcceptsPublicIssuerWithTrailingSlash() + { + ServiceCollection services = CreateServices(new() { ["BotFramework:BotTokenIssuer"] = "https://api.botframework.com/" }); + + services.AddTeamsBotApplication(options => options.UseSocketMode()); + } + + public static TheoryData> InvalidOptions => new() + { + o => o.Geos = [], + o => o.Geos = ["amer", null!], + o => o.Geos = ["amer", " AMER/"], + o => o.StartupTimeout = TimeSpan.FromSeconds(-1), + o => o.ReadinessTimeout = TimeSpan.Zero, + o => o.KeepAliveInterval = TimeSpan.Zero, + o => o.ServerTimeout = TimeSpan.Zero, + }; + + [Theory] + [MemberData(nameof(InvalidOptions))] + public async Task InvalidOptions_AreAcceptedAtRegistrationAndRejectedWhenTheHostStarts(Action configure) + { + ServiceCollection services = CreateServices(); + services.AddTeamsBotApplication(options => options.UseSocketMode(configure)); + + await using ServiceProvider provider = services.BuildServiceProvider(); + IHostedService hosted = Assert.Single(provider.GetServices().OfType()); + + await Assert.ThrowsAnyAsync(() => hosted.StartAsync(CancellationToken.None)); + } + + [Fact] + public async Task Negotiator_UsesTheBotTokenForTheBotFrameworkScopeOnAPlainClient() + { + List scopes = []; + Mock header = new(); + header + .Setup(h => h.CreateAuthorizationHeaderForAppAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string scope, AuthorizationHeaderProviderOptions _, CancellationToken __) => + { + scopes.Add(scope); + return Task.FromResult("Bearer bot-token"); + }); + RecordingHandler handler = new(); + + ServiceCollection services = CreateServices(); + services.AddTeamsBotApplication(options => options.UseSocketMode()); + services.AddKeyedSingleton("AzureAd", new BotTokenProvider(header.Object)); + services.AddHttpClient(SocketModeServiceRegistration.NegotiatorHttpClientName).ConfigurePrimaryHttpMessageHandler(() => handler); + + using ServiceProvider provider = services.BuildServiceProvider(); + SocketModeNegotiateResponse response = await provider.GetRequiredService() + .NegotiateAsync(new Uri("https://botapi.skype.com/amer/v3/websockets/connect")); + + Assert.Equal("signalr-token", response.AccessToken); + Assert.Equal(["https://api.botframework.com/.default"], scopes); + HttpRequestMessage request = Assert.Single(handler.Requests); + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal("bot-token", request.Headers.Authorization?.Parameter); + } + + [Fact] + public async Task HostedService_StartsEveryGeoDispatchesToTheAppAndStops() + { + FakeConnectionFactory factory = new(); + ServiceCollection services = CreateServices(); + services.AddSingleton(factory); + services.AddTeamsBotApplication(options => options.UseSocketMode(socket => socket.Geos = ["amer", "emea"])); + + await using ServiceProvider provider = services.BuildServiceProvider(); + object body = new { hello = "world" }; + provider.GetRequiredService() + .OnInvoke((_, _) => Task.FromResult(new InvokeResponse(202, body))); + IHostedService hosted = Assert.Single(provider.GetServices().OfType()); + SocketModeTransport transport = provider.GetRequiredService(); + + await hosted.StartAsync(CancellationToken.None); + + Assert.Equal(SocketModeStatus.Ready, transport.Status); + Assert.Equal(2, factory.Connections.Count); + + SocketReplyFrame? reply = await factory.Connections[0].Handlers.OnActivity(new SocketActivityEnvelope + { + EnvelopeId = "envelope-1", + Type = TeamsActivityTypes.Invoke, + Payload = JsonSerializer.SerializeToElement(new { type = TeamsActivityTypes.Invoke, name = InvokeNames.TaskFetch }), + }); + + Assert.NotNull(reply); + Assert.Equal(202, reply.Status); + Assert.Same(body, reply.Body); + Assert.Equal(ClientId, reply.BotKey); + + await hosted.StopAsync(CancellationToken.None); + + Assert.Equal(SocketModeStatus.Stopped, transport.Status); + Assert.All(factory.Connections, connection => Assert.True(connection.Disposed)); + } + + [Fact] + public async Task HostedService_PropagatesStartupFailure() + { + IOException failure = new("negotiate failed"); + FakeConnectionFactory factory = new() { StartError = failure }; + ServiceCollection services = CreateServices(); + services.AddSingleton(factory); + services.AddTeamsBotApplication(options => options.UseSocketMode(socket => socket.StartupTimeout = TimeSpan.Zero)); + + await using ServiceProvider provider = services.BuildServiceProvider(); + IHostedService hosted = Assert.Single(provider.GetServices().OfType()); + + IOException thrown = await Assert.ThrowsAsync(() => hosted.StartAsync(CancellationToken.None)); + + Assert.Same(failure, thrown); + Assert.Equal(SocketModeStatus.Stopped, provider.GetRequiredService().Status); + } + + [Fact] + public async Task DispatchAsync_NonInvokeReturnsOkWithoutBody() + { + ServiceCollection services = CreateServices(); + services.AddTeamsBotApplication(); + await using ServiceProvider provider = services.BuildServiceProvider(); + TeamsBotApplication app = provider.GetRequiredService(); + app.OnMessage((_, _) => Task.CompletedTask); + + SocketDispatchResult result = await SocketModeServiceRegistration.DispatchAsync( + app, + new Core.Schema.CoreActivity { Type = TeamsActivityTypes.Message }); + + Assert.Equal(new SocketDispatchResult(200), result); + } + + [Fact] + public async Task UseTeamsBotApplication_WithSocketMode_ThrowsAndMapsNothing() + { + await using WebApplication app = BuildWebApplication(options => options.UseSocketMode()); + + InvalidOperationException exception = Assert.Throws(() => app.UseTeamsBotApplication()); + + Assert.Contains("without a web server", exception.Message, StringComparison.Ordinal); + Assert.Empty(MappedRoutes(app)); + } + + [Fact] + public async Task WebApplication_WithSocketMode_FailsToStart() + { + FakeConnectionFactory factory = new(); + await using WebApplication app = BuildWebApplication(options => options.UseSocketMode(), factory); + + InvalidOperationException exception = await Assert.ThrowsAsync(() => app.StartAsync()); + + Assert.Contains("without a web server", exception.Message, StringComparison.Ordinal); + Assert.Empty(factory.Connections); + } + + [Fact] + public void GenericHost_InDevelopment_PassesServiceValidation() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + Args = [], + EnvironmentName = Environments.Development, + }); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = ClientId, + ["AzureAd:TenantId"] = "socket-tenant-id", + }); + builder.Services.AddTeamsBotApplication(options => options.UseSocketMode()); + + using IHost host = builder.Build(); + + Assert.NotNull(host.UseTeamsBotApplication()); + } + + [Fact] + public async Task GenericHost_StartsSocketModeAndExposesTheApp() + { + FakeConnectionFactory factory = new(); + HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] }); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = ClientId, + ["AzureAd:TenantId"] = "socket-tenant-id", + }); + builder.Services.AddSingleton(factory); + builder.Services.AddTeamsBotApplication(options => options.UseSocketMode()); + using IHost host = builder.Build(); + + TeamsBotApplication app = host.UseTeamsBotApplication(); + await host.StartAsync(); + + Assert.Same(host.Services.GetRequiredService(), app); + Assert.Equal(SocketModeStatus.Ready, host.Services.GetRequiredService().Status); + Assert.Equal(3, factory.Connections.Count); + + await host.StopAsync(); + + Assert.Equal(SocketModeStatus.Stopped, host.Services.GetRequiredService().Status); + } + + [Fact] + public async Task UseTeamsBotApplication_WithoutSocketMode_MapsTheHttpEndpoint() + { + await using WebApplication app = BuildWebApplication(_ => { }); + + app.UseTeamsBotApplication(); + + Assert.Equal(["api/messages"], MappedRoutes(app)); + } + + [Fact] + public void GenericHost_WithSocketMode_CustomAppReturnsTheRegisteredApp() + { + using IHost host = BuildGenericHost(options => options.UseSocketMode()); + + Assert.Same(host.Services.GetRequiredService(), host.UseTeamsBotApplication()); + } + + [Fact] + public void GenericHost_SocketModeDisabledWithFalse_ThrowsForMissingWebServer() + { + using IHost host = BuildGenericHost(options => options.UseSocketMode(socket => socket.Geos = ["amer"]).UseSocketMode(false)); + + InvalidOperationException exception = Assert.Throws(() => host.UseTeamsBotApplication()); + + Assert.Contains("requires a web server", exception.Message, StringComparison.Ordinal); + Assert.Empty(host.Services.GetServices().OfType()); + } + + [Fact] + public void GenericHost_WithoutSocketMode_ThrowsForMissingWebServer() + { + using IHost host = BuildGenericHost(_ => { }); + + InvalidOperationException exception = Assert.Throws(() => host.UseTeamsBotApplication()); + + Assert.Contains("requires a web server", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task WebApplication_AsHost_WithSocketMode_ThrowsAndMapsNothing() + { + await using WebApplication app = BuildWebApplication(options => options.UseSocketMode()); + + InvalidOperationException exception = Assert.Throws(() => ((IHost)app).UseTeamsBotApplication()); + + Assert.Contains("without a web server", exception.Message, StringComparison.Ordinal); + Assert.Empty(MappedRoutes(app)); + } + + [Fact] + public async Task WebApplication_AsHost_WithoutSocketMode_MapsTheHttpEndpoint() + { + await using WebApplication app = BuildWebApplication(_ => { }); + + ((IHost)app).UseTeamsBotApplication(); + + Assert.Equal(["api/messages"], MappedRoutes(app)); + } + + [Fact] + public async Task EndpointRouteBuilder_WithSocketMode_ThrowsAndMapsNothing() + { + await using WebApplication app = BuildWebApplication(options => options.UseSocketMode()); + + InvalidOperationException exception = Assert.Throws(() => ((IEndpointRouteBuilder)app).UseTeamsBotApplication()); + + Assert.Contains("without a web server", exception.Message, StringComparison.Ordinal); + Assert.Empty(MappedRoutes(app)); + } + + [Fact] + public async Task WebApplication_CustomRoute_MapsTheHttpEndpoint() + { + await using WebApplication app = BuildWebApplication(_ => { }); + + app.UseTeamsBotApplication("custom/messages"); + + Assert.Equal(["custom/messages"], MappedRoutes(app)); + } + + private static IHost BuildGenericHost(Action configure) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] }); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = ClientId, + ["AzureAd:TenantId"] = "socket-tenant-id", + }); + builder.Services.AddTeamsBotApplication(configure); + return builder.Build(); + } + + private static WebApplication BuildWebApplication(Action configure, ISocketConnectionFactory? factory = null) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = ClientId, + ["AzureAd:TenantId"] = "socket-tenant-id", + }); + if (factory is not null) + { + builder.Services.AddSingleton(factory); + } + + builder.Services.AddTeamsBotApplication(configure); + return builder.Build(); + } + + private static string[] MappedRoutes(WebApplication app) + => [.. ((IEndpointRouteBuilder)app).DataSources + .SelectMany(source => source.Endpoints) + .OfType() + .Select(endpoint => endpoint.RoutePattern.RawText ?? string.Empty)]; + + [Fact] + public async Task GenericHost_MessageOverSocket_RepliesThroughTheConversationApi() + { + FakeConnectionFactory factory = new(); + OutboundHandler outbound = new(); + Mock header = new(); + header + .Setup(h => h.CreateAuthorizationHeaderForAppAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("Bearer bot-token"); + + HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] }); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = ClientId, + ["AzureAd:TenantId"] = "socket-tenant-id", + }); + builder.Services.AddSingleton(factory); + builder.Services.AddTeamsBotApplication(options => options.UseSocketMode(socket => socket.Geos = ["amer"])); + builder.Services.AddSingleton(header.Object); + builder.Services.AddHttpClient("BotConversationClient").ConfigurePrimaryHttpMessageHandler(() => outbound); + using IHost host = builder.Build(); + + TeamsBotApplication app = host.UseTeamsBotApplication(); + app.OnMessage((context, cancellationToken) => context.SendAsync($"You said: {context.Activity.Text}", cancellationToken)); + await host.StartAsync(); + + SocketReplyFrame? ack = await Assert.Single(factory.Connections).Handlers.OnActivity(new SocketActivityEnvelope + { + EnvelopeId = "envelope-1", + Type = TeamsActivityTypes.Message, + Payload = JsonSerializer.SerializeToElement(new + { + type = TeamsActivityTypes.Message, + id = "incoming-1", + text = "hello", + channelId = "msteams", + serviceUrl = "https://smba.trafficmanager.net/amer/", + conversation = new { id = "conversation-1" }, + from = new { id = "user-1" }, + recipient = new { id = ClientId }, + }), + }); + await host.StopAsync(); + + Assert.Equal(200, ack!.Status); + (HttpRequestMessage request, string body) = Assert.Single(outbound.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.StartsWith("https://smba.trafficmanager.net/amer/v3/conversations/conversation-1/activities", request.RequestUri!.AbsoluteUri, StringComparison.Ordinal); + Assert.Equal("bot-token", request.Headers.Authorization?.Parameter); + Assert.Equal("You said: hello", JsonDocument.Parse(body).RootElement.GetProperty("text").GetString()); + } + + private sealed class OutboundHandler : HttpMessageHandler + { + public List<(HttpRequestMessage Request, string Body)> Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + lock (Requests) + { + Requests.Add((request, body)); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"id":"reply-1"}""", Encoding.UTF8, "application/json"), + }; + } + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """{"url":"https://example.service.signalr.net/client/?hub=bot","accessToken":"signalr-token","expiresIn":3600}""", + Encoding.UTF8, + "application/json"), + }); + } + } + + private sealed class FakeConnectionFactory : ISocketConnectionFactory + { + public Exception? StartError { get; init; } + + public List Connections { get; } = []; + + public ISocketConnection Create(Uri negotiateUri, SocketConnectionHandlers handlers) + { + FakeConnection connection = new(handlers, StartError); + lock (Connections) + { + Connections.Add(connection); + } + + return connection; + } + } + + private sealed class FakeConnection(SocketConnectionHandlers handlers, Exception? startError) : ISocketConnection + { + public SocketConnectionHandlers Handlers { get; } = handlers; + + public bool Disposed { get; private set; } + + public TimeSpan? TokenLifetime => null; + + public Task StartAsync(CancellationToken cancellationToken) + { + if (startError is not null) + { + return Task.FromException(startError); + } + + Handlers.OnReady(new SocketReadyFrame { ConnectionId = Guid.NewGuid().ToString() }); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } + } +} diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeOptionsTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeOptionsTests.cs new file mode 100644 index 00000000..ca07c73a --- /dev/null +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeOptionsTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.SocketMode; + +namespace Microsoft.Teams.Apps.UnitTests.SocketMode; + +public class SocketModeOptionsTests +{ + [Fact] + public void Defaults_MatchTheOtherSdks() + { + SocketModeOptions options = new(); + + Assert.Equal(new Uri("https://botapi.skype.com"), options.NegotiateBaseUrl); + Assert.Equal(["amer", "emea", "apac"], options.Geos); + Assert.Equal(TimeSpan.FromSeconds(30), options.StartupTimeout); + Assert.Null(options.ReconnectDelays); + Assert.Equal(TimeSpan.FromSeconds(30), options.ReadinessTimeout); + Assert.Equal(TimeSpan.FromSeconds(15), options.KeepAliveInterval); + Assert.Equal(TimeSpan.FromSeconds(30), options.ServerTimeout); + } + + [Fact] + public void ToTransportOptions_PassesInvalidValuesThroughForTheTransportToReject() + { + SocketModeTransportOptions transport = new SocketModeOptions { Geos = null!, StartupTimeout = TimeSpan.FromSeconds(-1) } + .ToTransportOptions(); + + Assert.Null(transport.Geos); + Assert.Equal(TimeSpan.FromSeconds(-1), transport.StartupTimeout); + } + + [Fact] + public void ToTransportOptions_CopiesTransportSettings() + { + List geos = ["emea"]; + List delays = [TimeSpan.FromSeconds(1)]; + SocketModeOptions options = new() + { + NegotiateBaseUrl = new Uri("https://example.test"), + Geos = geos, + StartupTimeout = TimeSpan.FromSeconds(5), + ReconnectDelays = delays, + }; + + SocketModeTransportOptions transport = options.ToTransportOptions(); + geos.Add("apac"); + delays.Add(TimeSpan.FromSeconds(2)); + + Assert.Equal(new Uri("https://example.test"), transport.NegotiateBaseUri); + Assert.Equal(["emea"], transport.Geos); + Assert.Equal(TimeSpan.FromSeconds(5), transport.StartupTimeout); + Assert.Equal([TimeSpan.FromSeconds(1)], transport.ReconnectDelays!); + } + + [Fact] + public void ToTransportOptions_TreatsAnEmptyScheduleAsDefaultBackoff() + { + Assert.Null(new SocketModeOptions { ReconnectDelays = [] }.ToTransportOptions().ReconnectDelays); + } + + [Fact] + public void UseSocketMode_AppliesTheDelegate() + { + TeamsBotApplicationOptions options = new(); + + Assert.Null(options.SocketMode); + Assert.Same(options, options.UseSocketMode(o => o.Geos = ["emea"])); + Assert.Equal(["emea"], options.SocketMode!.Geos); + } + + [Fact] + public void UseSocketMode_WithoutArgumentsUsesDefaults() + { + TeamsBotApplicationOptions options = new(); + + options.UseSocketMode(); + + Assert.Equal(SocketModeProtocol.DefaultGeos, options.SocketMode!.Geos); + } + + [Fact] + public void UseSocketMode_TrueUsesDefaults() + { + TeamsBotApplicationOptions options = new(); + + Assert.Same(options, options.UseSocketMode(true)); + + Assert.Equal(SocketModeProtocol.DefaultGeos, options.SocketMode!.Geos); + } + + [Fact] + public void UseSocketMode_FalseClearsEarlierConfiguration() + { + TeamsBotApplicationOptions options = new(); + options.UseSocketMode(o => o.Geos = ["emea"]); + + Assert.Same(options, options.UseSocketMode(false)); + + Assert.Null(options.SocketMode); + } + + [Fact] + public void UseSocketMode_NullDelegateThrows() + => Assert.Throws(() => new TeamsBotApplicationOptions().UseSocketMode(null!)); +} diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index 49b86699..15cf9db7 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -3,8 +3,10 @@ using System.Net; using System.Text.Json; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Apps.SocketMode; +using Microsoft.Teams.Core; using Microsoft.Teams.Core.Schema; namespace Microsoft.Teams.Apps.UnitTests.SocketMode; @@ -237,6 +239,36 @@ public async Task Dispatch_HandlerFailureReturns500AndReportsError(string type, Assert.Same(failure, Assert.Single(reported)); } + [Fact] + public async Task Dispatch_LogsFailuresThePipelineHasNotAlreadyLogged() + { + ErrorLogger logger = new(); + InvalidOperationException failure = new("dispatch failed"); + Harness harness = new() { Logger = logger, Dispatch = _ => throw failure }; + FakeConnection connection = (await harness.StartReadyAsync())[0]; + + await connection.ActivityAsync(Envelope("message", """{"type":"message"}""")); + + Assert.Same(failure, Assert.Single(logger.Errors)); + } + + [Fact] + public async Task Dispatch_DoesNotLogHandlerFailuresTheBotAlreadyLogged() + { + ErrorLogger logger = new(); + Harness harness = new() + { + Logger = logger, + Dispatch = activity => throw new BotHandlerException("handler failed", new InvalidOperationException(), activity), + }; + FakeConnection connection = (await harness.StartReadyAsync())[0]; + + SocketReplyFrame? reply = await connection.ActivityAsync(Envelope("invoke", """{"type":"invoke"}""")); + + Assert.Equal(500, reply?.Status); + Assert.Empty(logger.Errors); + } + [Fact] public async Task Dispatch_FailingErrorObserverStillReturns500() { @@ -333,6 +365,38 @@ private static async Task WaitUntilAsync(Func condition) } } + private sealed class ErrorLogger : ILogger + { + private readonly List _errors = []; + + internal Exception?[] Errors + { + get + { + lock (_errors) + { + return [.. _errors]; + } + } + } + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Error) + { + lock (_errors) + { + _errors.Add(exception); + } + } + } + } + private sealed class Harness { private readonly SocketModeTransportOptions _options; @@ -354,6 +418,8 @@ internal Harness(SocketModeTransportOptions? options = null) internal Random? Random { get; init; } + internal ILogger Logger { get; init; } = NullLogger.Instance; + internal SocketModeTransport Transport => _transport ??= new SocketModeTransport( _options, Factory, @@ -366,7 +432,7 @@ internal Harness(SocketModeTransportOptions? options = null) return Dispatch(activity); }, - NullLogger.Instance, + Logger, "bot-id", OnError, new FixedTimeProvider(), diff --git a/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationTests.cs b/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationTests.cs index e88eb541..30291cd3 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/TeamsBotApplicationTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Reflection; +using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -53,7 +54,152 @@ public void HasMatchingRoute_ReturnsTrueForRegisteredInvokeHandler() Assert.False(app.HasMatchingRoute(new CoreActivity(ActivityType.Message))); } - private static TeamsBotApplication CreateApp() + [Fact] + public async Task ProcessAsync_HttpContext_InvokeWritesStatusAndJsonBody() + { + DefaultHttpContext httpContext = CreateHttpContext(new InvokeActivity(InvokeNames.TaskFetch)); + MemoryStream responseBody = new(); + httpContext.Response.Body = responseBody; + // ASP.NET populates the accessor for real requests; the invoke response is written through it. + TeamsBotApplication app = CreateApp(new HttpContextAccessor { HttpContext = httpContext }); + app.OnInvoke((_, _) => Task.FromResult(new InvokeResponse(200, new { hello = "world" }))); + + await app.ProcessAsync(httpContext); + + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.StartsWith("application/json", httpContext.Response.ContentType, StringComparison.Ordinal); + Assert.Equal("{\"hello\":\"world\"}", Encoding.UTF8.GetString(responseBody.ToArray())); + } + + [Fact] + public async Task ProcessWithInvokeResponseAsync_InvokeReturnsResponse() + { + TeamsBotApplication app = CreateApp(); + object body = new { hello = "world" }; + app.OnInvoke((_, _) => Task.FromResult(new InvokeResponse(202, body))); + + InvokeResponse? response = await app.ProcessWithInvokeResponseAsync(new InvokeActivity(InvokeNames.TaskFetch), user: null, correlationVector: null); + + Assert.NotNull(response); + Assert.Equal(202, response.Status); + Assert.Same(body, response.Body); + } + + [Fact] + public async Task ProcessWithInvokeResponseAsync_UnhandledInvokeReturnsNotImplemented() + { + TeamsBotApplication app = CreateApp(); + app.OnMessage((_, _) => Task.CompletedTask); + + InvokeResponse? response = await app.ProcessWithInvokeResponseAsync(new InvokeActivity(InvokeNames.TaskFetch), user: null, correlationVector: null); + + Assert.NotNull(response); + Assert.Equal(501, response.Status); + } + + [Fact] + public async Task ProcessWithInvokeResponseAsync_MessageReturnsNull() + { + TeamsBotApplication app = CreateApp(); + bool handled = false; + app.OnMessage((_, _) => + { + handled = true; + return Task.CompletedTask; + }); + + InvokeResponse? response = await app.ProcessWithInvokeResponseAsync(new CoreActivity(ActivityType.Message), user: null, correlationVector: null); + + Assert.True(handled); + Assert.Null(response); + } + + [Fact] + public async Task ProcessWithInvokeResponseAsync_ConcurrentTurnsReturnOwnResponses() + { + TeamsBotApplication app = CreateApp(); + TaskCompletionSource bothStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + int started = 0; + app.OnInvoke(async (ctx, _) => + { + if (Interlocked.Increment(ref started) == 2) + { + bothStarted.SetResult(); + } + + await bothStarted.Task; + return new InvokeResponse(200, ctx.Activity.Id); + }); + + Task first = app.ProcessWithInvokeResponseAsync(new InvokeActivity(InvokeNames.TaskFetch) { Id = "first" }, user: null, correlationVector: null); + Task second = app.ProcessWithInvokeResponseAsync(new InvokeActivity(InvokeNames.TaskFetch) { Id = "second" }, user: null, correlationVector: null); + InvokeResponse?[] responses = await Task.WhenAll(first, second); + + Assert.Equal("first", responses[0]?.Body); + Assert.Equal("second", responses[1]?.Body); + } + + [Fact] + public async Task ProcessWithInvokeResponseAsync_NestedAppDoesNotStealResponse() + { + DefaultHttpContext ambient = new(); + MemoryStream responseBody = new(); + ambient.Response.Body = responseBody; + TeamsBotApplication inner = CreateApp(new HttpContextAccessor { HttpContext = ambient }); + inner.OnInvoke((_, _) => Task.FromResult(new InvokeResponse(418))); + TeamsBotApplication outer = CreateApp(); + outer.OnInvoke(async (_, ct) => + { + // Another app's turn on the same async flow must not write into this turn's capture. + await inner.OnActivity!(new InvokeActivity(InvokeNames.TaskFetch), ct); + return new InvokeResponse(200); + }); + + InvokeResponse? response = await outer.ProcessWithInvokeResponseAsync(new InvokeActivity(InvokeNames.TaskFetch), user: null, correlationVector: null); + + Assert.Equal(200, response?.Status); + Assert.Equal(418, ambient.Response.StatusCode); + } + + [Fact] + public async Task ProcessWithInvokeResponseAsync_DoesNotWriteToAmbientHttpContext() + { + DefaultHttpContext ambient = new(); + MemoryStream responseBody = new(); + ambient.Response.Body = responseBody; + TeamsBotApplication app = CreateApp(new HttpContextAccessor { HttpContext = ambient }); + app.OnInvoke((_, _) => Task.FromResult(new InvokeResponse(202, new { hello = "world" }))); + + await app.ProcessWithInvokeResponseAsync(new InvokeActivity(InvokeNames.TaskFetch), user: null, correlationVector: null); + + Assert.Equal(200, ambient.Response.StatusCode); + Assert.Equal(0, responseBody.Length); + } + + [Fact] + public async Task OnActivity_InvokedDirectly_FallsBackToHttpContextWrite() + { + DefaultHttpContext ambient = new(); + MemoryStream responseBody = new(); + ambient.Response.Body = responseBody; + TeamsBotApplication app = CreateApp(new HttpContextAccessor { HttpContext = ambient }); + app.OnInvoke((_, _) => Task.FromResult(new InvokeResponse(202, new { hello = "world" }))); + + await app.OnActivity!(new InvokeActivity(InvokeNames.TaskFetch), CancellationToken.None); + + Assert.Equal(202, ambient.Response.StatusCode); + Assert.Equal("{\"hello\":\"world\"}", Encoding.UTF8.GetString(responseBody.ToArray())); + } + + private static DefaultHttpContext CreateHttpContext(CoreActivity activity) + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(activity.ToJson())); + httpContext.Request.ContentType = "application/json"; + return httpContext; + } + + private static TeamsBotApplication CreateApp(IHttpContextAccessor? httpContextAccessor = null) { Mock mockUserTokenClient = new( new HttpClient(), @@ -71,7 +217,7 @@ private static TeamsBotApplication CreateApp() return new TeamsBotApplication( apiClient, - new HttpContextAccessor(), + httpContextAccessor ?? new HttpContextAccessor(), NullLogger.Instance, new TeamsBotApplicationOptions { AppId = "test-app-id" }); } diff --git a/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs b/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs index 82f92e2e..1712549f 100644 --- a/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs +++ b/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs @@ -292,6 +292,125 @@ public async Task ProcessAsync_NoServiceUrlClaim_ProcessesSuccessfully() Assert.True(onActivityCalled); } + [Fact] + public async Task ProcessAsync_CoreActivity_RunsMiddlewareAndOnActivity() + { + BotApplication botApp = CreateBotApplication(); + List calls = []; + Mock middleware = new(); + middleware + .Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, next, ct) => + { + calls.Add("middleware"); + return next(ct); + }); + botApp.UseMiddleware(middleware.Object); + CoreActivity activity = new(ActivityType.Message) { Id = "act123" }; + CoreActivity? received = null; + botApp.OnActivity = (act, _) => + { + calls.Add("onActivity"); + received = act; + return Task.CompletedTask; + }; + + await botApp.ProcessAsync(activity, user: null, correlationVector: null); + + Assert.Equal(["middleware", "onActivity"], calls); + Assert.Same(activity, received); + } + + [Fact] + public async Task ProcessAsync_CoreActivity_ServiceUrlClaimMismatch_ThrowsInvalidDataException() + { + BotApplication botApp = CreateBotApplication(); + bool onActivityCalled = false; + botApp.OnActivity = (_, _) => + { + onActivityCalled = true; + return Task.CompletedTask; + }; + CoreActivity activity = new(ActivityType.Message) { ServiceUrl = new Uri("https://smba.trafficmanager.net/teams/") }; + ClaimsPrincipal user = new(new ClaimsIdentity([new Claim("serviceurl", "https://evil.example.com/")])); + + await Assert.ThrowsAsync(() => botApp.ProcessAsync(activity, user, correlationVector: null)); + + Assert.False(onActivityCalled); + } + + [Fact] + public async Task ProcessAsync_CoreActivity_ServiceUrlClaimMatches_ProcessesSuccessfully() + { + BotApplication botApp = CreateBotApplication(); + bool onActivityCalled = false; + botApp.OnActivity = (_, _) => + { + onActivityCalled = true; + return Task.CompletedTask; + }; + CoreActivity activity = new(ActivityType.Message) { ServiceUrl = new Uri("https://smba.trafficmanager.net/teams/") }; + ClaimsPrincipal user = new(new ClaimsIdentity([new Claim("serviceurl", "https://smba.trafficmanager.net/teams/")])); + + await botApp.ProcessAsync(activity, user, correlationVector: null); + + Assert.True(onActivityCalled); + } + + [Fact] + public async Task ProcessAsync_CoreActivity_HandlerThrows_ThrowsBotHandlerException() + { + BotApplication botApp = CreateBotApplication(); + botApp.OnActivity = (_, _) => throw new InvalidOperationException("Test exception"); + + BotHandlerException exception = await Assert.ThrowsAsync(() => + botApp.ProcessAsync(new CoreActivity(ActivityType.Message), user: null, correlationVector: null)); + + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task ProcessAsync_CoreActivity_NullActivity_ThrowsArgumentNullException() + { + BotApplication botApp = CreateBotApplication(); + + await Assert.ThrowsAsync(() => + botApp.ProcessAsync((CoreActivity)null!, user: null, correlationVector: null)); + } + + [Fact] + public async Task ProcessAsync_HttpContext_DelegatesToCoreActivityOverload() + { + RecordingBot botApp = new(); + CoreActivity activity = new(ActivityType.Message) { Id = "act123" }; + DefaultHttpContext httpContext = CreateHttpContextWithActivity(activity); + httpContext.Request.Headers["MS-CV"] = "cv-123"; + ClaimsPrincipal user = new(new ClaimsIdentity([new Claim("aud", "test-app-id")])); + httpContext.User = user; + + await botApp.ProcessAsync(httpContext); + + Assert.Equal("act123", botApp.Activity?.Id); + Assert.Same(user, botApp.User); + Assert.Equal("cv-123", botApp.CorrelationVector); + } + + private sealed class RecordingBot() + : BotApplication(CreateMockConversationClient(), CreateMockUserTokenClient(), NullLogger.Instance) + { + public CoreActivity? Activity { get; private set; } + public ClaimsPrincipal? User { get; private set; } + public string? CorrelationVector { get; private set; } + + public override Task ProcessAsync(CoreActivity activity, ClaimsPrincipal? user, string? correlationVector, CancellationToken cancellationToken = default) + { + Activity = activity; + User = user; + CorrelationVector = correlationVector; + return Task.CompletedTask; + } + } + private static BotApplicationOptions CreateOptions(string appId) => new() { AppId = appId };