// 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;
}