// 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.Diagnostics; 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(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 virtual 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 virtual 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()); } string serviceUrlFromClaims = httpContext.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); throw new InvalidDataException("ServiceUrl in activity payload does not match serviceurl JWT claim."); //$"ServiceUrl in activity ({activity.ServiceUrl}) does not match serviceUrl claim ({serviceUrlFromClaims})." } KeyValuePair activityTypeTag = new(Telemetry.Tags.ActivityType, activity.Type); Telemetry.ActivitiesReceived.Add(1, activityTypeTag); using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.Turn, ActivityKind.Internal); if (span is not null) { span.SetTag(Telemetry.Tags.ActivityType, activity.Type); span.SetTag(Telemetry.Tags.ActivityId, activity.Id); span.SetTag(Telemetry.Tags.ConversationId, activity.Conversation?.Id); span.SetTag(Telemetry.Tags.ChannelId, activity.ChannelId); span.SetTag(Telemetry.Tags.BotId, AppId); span.SetTag(Telemetry.Tags.ServiceUrl, activity.ServiceUrl?.ToString()); } long startTimestamp = Stopwatch.GetTimestamp(); // 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); Telemetry.HandlerErrors.Add(1, activityTypeTag); span?.SetStatus(ActivityStatusCode.Error, "timeout"); } catch (Exception ex) { _logger.ActivityProcessingError(ex, activity.Id); Telemetry.HandlerErrors.Add(1, activityTypeTag); span.RecordException(ex); throw new BotHandlerException("Error processing activity", ex, activity); } finally { _logger.ActivityProcessingFinished(activity.Id); double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; Telemetry.TurnDuration.Record(elapsedMs, activityTypeTag); } } } /// /// 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; }