microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/src/Microsoft.Teams.Core/BotApplication.cs
321lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | using System.Diagnostics; |
| 5 | using Microsoft.AspNetCore.Http; |
| 6 | using Microsoft.Extensions.Logging; |
| 7 | using Microsoft.Extensions.Logging.Abstractions; |
| 8 | using Microsoft.Teams.Core.Diagnostics; |
| 9 | using Microsoft.Teams.Core.Hosting; |
| 10 | using Microsoft.Teams.Core.Schema; |
| 11 | |
| 12 | namespace Microsoft.Teams.Core; |
| 13 | |
| 14 | /// <summary> |
| 15 | /// Represents a bot application that receives and processes activities from a messaging channel. |
| 16 | /// </summary> |
| 17 | /// <remarks> |
| 18 | /// <para> |
| 19 | /// <see cref="BotApplication"/> is the central entry point for handling incoming bot activities. |
| 20 | /// Register it with the host using <see cref="AddBotApplicationExtensions.AddBotApplication"/> and |
| 21 | /// map it to an endpoint with <see cref="AddBotApplicationExtensions.UseBotApplication"/>. |
| 22 | /// </para> |
| 23 | /// <example> |
| 24 | /// <strong>Minimal setup in Program.cs:</strong> |
| 25 | /// <code> |
| 26 | /// var builder = WebApplication.CreateBuilder(args); |
| 27 | /// builder.Services.AddBotApplication(); |
| 28 | /// |
| 29 | /// var app = builder.Build(); |
| 30 | /// var bot = app.UseBotApplication(); |
| 31 | /// |
| 32 | /// bot.OnActivity = async (activity, ct) => |
| 33 | /// { |
| 34 | /// await bot.SendActivityAsync( |
| 35 | /// CoreActivity.CreateBuilder() |
| 36 | /// .WithType(ActivityType.Message) |
| 37 | /// .WithConversation(activity.Conversation) |
| 38 | /// .WithServiceUrl(activity.ServiceUrl) |
| 39 | /// .WithProperty("text", "Hello!") |
| 40 | /// .Build(), |
| 41 | /// ct); |
| 42 | /// }; |
| 43 | /// |
| 44 | /// app.Run(); |
| 45 | /// </code> |
| 46 | /// </example> |
| 47 | /// <example> |
| 48 | /// <strong>Subclassing for more complex scenarios:</strong> |
| 49 | /// <code> |
| 50 | /// public class MyBot : BotApplication |
| 51 | /// { |
| 52 | /// public MyBot(ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger<MyBot> logger) |
| 53 | /// : base(conversationClient, userTokenClient, logger) |
| 54 | /// { |
| 55 | /// OnActivity = HandleActivityAsync; |
| 56 | /// } |
| 57 | /// |
| 58 | /// private async Task HandleActivityAsync(CoreActivity activity, CancellationToken ct) |
| 59 | /// { |
| 60 | /// if (activity.Type == ActivityType.Message) |
| 61 | /// { |
| 62 | /// // Echo the user's message back |
| 63 | /// await SendActivityAsync( |
| 64 | /// CoreActivity.CreateBuilder() |
| 65 | /// .WithType(ActivityType.Message) |
| 66 | /// .WithConversation(activity.Conversation) |
| 67 | /// .WithServiceUrl(activity.ServiceUrl) |
| 68 | /// .WithProperty("text", $"You said: {activity.Properties["text"]}") |
| 69 | /// .Build(), |
| 70 | /// ct); |
| 71 | /// } |
| 72 | /// } |
| 73 | /// } |
| 74 | /// </code> |
| 75 | /// </example> |
| 76 | /// </remarks> |
| 77 | public class BotApplication |
| 78 | { |
| 79 | private readonly ILogger<BotApplication> _logger; |
| 80 | private readonly ConversationClient? _conversationClient; |
| 81 | private readonly UserTokenClient? _userTokenClient; |
| 82 | private readonly TimeSpan _processActivityTimeout = TimeSpan.FromMinutes(5); |
| 83 | internal TurnMiddleware MiddleWare { get; } |
| 84 | |
| 85 | /// <summary> |
| 86 | /// Creates a default instance, primarily for testing purposes. |
| 87 | /// The <see cref="ConversationClient"/> and <see cref="UserTokenClient"/> properties will not be initialized; |
| 88 | /// accessing them will throw <see cref="InvalidOperationException"/>. |
| 89 | /// </summary> |
| 90 | protected BotApplication() |
| 91 | { |
| 92 | _logger = NullLogger<BotApplication>.Instance; |
| 93 | AppId = string.Empty; |
| 94 | MiddleWare = new TurnMiddleware(); |
| 95 | } |
| 96 | |
| 97 | /// <summary> |
| 98 | /// Initializes a new instance of the <see cref="BotApplication"/> class with the specified conversation client, user token client, |
| 99 | /// logger, and optional application options. |
| 100 | /// </summary> |
| 101 | /// <param name="conversationClient">The client used to manage and interact with conversations for the bot.</param> |
| 102 | /// <param name="userTokenClient">The client used to manage user tokens for authentication.</param> |
| 103 | /// <param name="logger">The logger used to record operational and diagnostic information for the bot application.</param> |
| 104 | /// <param name="options">Options containing the application (client) ID, used for logging and diagnostics. Defaults to an empty instance if not provided.</param> |
| 105 | public BotApplication(ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger<BotApplication> logger, BotApplicationOptions? options = null) |
| 106 | { |
| 107 | options ??= new(); |
| 108 | _logger = logger; |
| 109 | AppId = options.AppId; |
| 110 | MiddleWare = new TurnMiddleware(); |
| 111 | MiddleWare.SetLogger(logger); |
| 112 | _conversationClient = conversationClient; |
| 113 | _userTokenClient = userTokenClient; |
| 114 | _processActivityTimeout = options.ProcessActivityTimeout; |
| 115 | logger.BotStarted(options.AppId, Version); |
| 116 | } |
| 117 | |
| 118 | |
| 119 | /// <summary> |
| 120 | /// Gets the application (client) ID configured for this bot (for example, the Azure AD app registration client ID). |
| 121 | /// </summary> |
| 122 | public string AppId { get; } |
| 123 | |
| 124 | /// <summary> |
| 125 | /// Gets the <see cref="Core.ConversationClient"/> used to send, update, and delete activities in conversations. |
| 126 | /// </summary> |
| 127 | /// <remarks>This property is only available when the bot is constructed via dependency injection or |
| 128 | /// with an explicit <see cref="Core.ConversationClient"/>. It throws <see cref="InvalidOperationException"/> |
| 129 | /// if accessed on a test instance created with the parameterless constructor.</remarks> |
| 130 | public ConversationClient ConversationClient => _conversationClient ?? throw new InvalidOperationException("ConversationClient not initialized"); |
| 131 | |
| 132 | /// <summary> |
| 133 | /// Gets the <see cref="Core.UserTokenClient"/> used to manage OAuth user tokens (sign-in, sign-out, token exchange). |
| 134 | /// </summary> |
| 135 | /// <remarks>This property is only available when the bot is constructed via dependency injection or |
| 136 | /// with an explicit <see cref="Core.UserTokenClient"/>. It throws <see cref="InvalidOperationException"/> |
| 137 | /// if accessed on a test instance created with the parameterless constructor.</remarks> |
| 138 | public UserTokenClient UserTokenClient => _userTokenClient ?? throw new InvalidOperationException("UserTokenClient not registered"); |
| 139 | |
| 140 | /// <summary> |
| 141 | /// Gets or sets the delegate that is invoked to handle each incoming activity. |
| 142 | /// </summary> |
| 143 | /// <remarks> |
| 144 | /// Assign a handler to process activities as they arrive. If <see langword="null"/>, incoming activities |
| 145 | /// pass through the middleware pipeline but are otherwise ignored. |
| 146 | /// <example> |
| 147 | /// <code> |
| 148 | /// bot.OnActivity = async (activity, ct) => |
| 149 | /// { |
| 150 | /// if (activity.Type == ActivityType.Message) |
| 151 | /// { |
| 152 | /// await bot.SendActivityAsync( |
| 153 | /// CoreActivity.CreateBuilder() |
| 154 | /// .WithType(ActivityType.Message) |
| 155 | /// .WithConversation(activity.Conversation) |
| 156 | /// .WithServiceUrl(activity.ServiceUrl) |
| 157 | /// .WithProperty("text", "Received your message!") |
| 158 | /// .Build(), |
| 159 | /// ct); |
| 160 | /// } |
| 161 | /// }; |
| 162 | /// </code> |
| 163 | /// </example> |
| 164 | /// </remarks> |
| 165 | public virtual Func<CoreActivity, CancellationToken, Task>? OnActivity { get; set; } |
| 166 | |
| 167 | /// <summary> |
| 168 | /// Processes an incoming HTTP request containing a bot activity. |
| 169 | /// </summary> |
| 170 | /// <remarks> |
| 171 | /// <para> |
| 172 | /// The request body is deserialized into a <see cref="CoreActivity"/>, run through the registered |
| 173 | /// middleware pipeline (see <see cref="UseMiddleware"/>), and finally dispatched to <see cref="OnActivity"/>. |
| 174 | /// </para> |
| 175 | /// <para> |
| 176 | /// A dedicated internal timeout (configurable via <see cref="BotApplicationOptions.ProcessActivityTimeout"/>, |
| 177 | /// default 5 minutes) is used instead of the HTTP request's cancellation token, because streaming handlers |
| 178 | /// may outlive the original HTTP connection. When a debugger is attached the timeout is disabled. |
| 179 | /// </para> |
| 180 | /// </remarks> |
| 181 | /// <param name="httpContext">The HTTP context containing the incoming bot activity request.</param> |
| 182 | /// <param name="cancellationToken">A cancellation token that can be used to cancel the initial deserialization. Note: a dedicated timeout governs activity processing.</param> |
| 183 | /// <returns>A task that represents the asynchronous activity processing operation.</returns> |
| 184 | /// <exception cref="InvalidOperationException">Thrown if the request body cannot be deserialized into a valid activity.</exception> |
| 185 | /// <exception cref="BotHandlerException">Thrown if an error occurs while processing the activity, wrapping the original exception and the offending <see cref="CoreActivity"/>.</exception> |
| 186 | public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToken cancellationToken = default) |
| 187 | { |
| 188 | ArgumentNullException.ThrowIfNull(httpContext); |
| 189 | ArgumentNullException.ThrowIfNull(_conversationClient); |
| 190 | |
| 191 | _logger.StartProcessingActivity(); |
| 192 | |
| 193 | CoreActivity activity = await CoreActivity.FromJsonStreamAsync(httpContext.Request.Body, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Invalid Activity"); |
| 194 | |
| 195 | string? correlationVector = httpContext.Request.GetCorrelationVector(); |
| 196 | _logger.ActivityReceived(activity.Type, activity.Id, activity.ServiceUrl, correlationVector); |
| 197 | |
| 198 | if (_logger.IsEnabled(LogLevel.Trace)) |
| 199 | { |
| 200 | _logger.ReceivedActivityJson(activity.ToJson()); |
| 201 | } |
| 202 | |
| 203 | string serviceUrlFromClaims = httpContext.User.Claims.FirstOrDefault(c => c.Type == "serviceurl")?.Value ?? string.Empty; |
| 204 | if (!string.IsNullOrEmpty(serviceUrlFromClaims) && !serviceUrlFromClaims.Equals(activity.ServiceUrl?.ToString(), StringComparison.Ordinal)) |
| 205 | { |
| 206 | _logger.LogServiceUrlClaimMismatch(activity.ServiceUrl, serviceUrlFromClaims); |
| 207 | throw new InvalidDataException("ServiceUrl in activity payload does not match serviceurl JWT claim."); |
| 208 | //$"ServiceUrl in activity ({activity.ServiceUrl}) does not match serviceUrl claim ({serviceUrlFromClaims})." |
| 209 | } |
| 210 | |
| 211 | KeyValuePair<string, object?> activityTypeTag = new(Telemetry.Tags.ActivityType, activity.Type); |
| 212 | Telemetry.ActivitiesReceived.Add(1, activityTypeTag); |
| 213 | |
| 214 | using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.Turn, ActivityKind.Internal); |
| 215 | if (span is not null) |
| 216 | { |
| 217 | span.SetTag(Telemetry.Tags.ActivityType, activity.Type); |
| 218 | span.SetTag(Telemetry.Tags.ActivityId, activity.Id); |
| 219 | span.SetTag(Telemetry.Tags.ConversationId, activity.Conversation?.Id); |
| 220 | span.SetTag(Telemetry.Tags.ChannelId, activity.ChannelId); |
| 221 | span.SetTag(Telemetry.Tags.BotId, AppId); |
| 222 | span.SetTag(Telemetry.Tags.ServiceUrl, activity.ServiceUrl?.ToString()); |
| 223 | } |
| 224 | |
| 225 | long startTimestamp = Stopwatch.GetTimestamp(); |
| 226 | |
| 227 | // TODO: Replace with structured scope data, ensure it works with OpenTelemetry and other logging providers |
| 228 | using (_logger.BeginActivityScope(activity.Type, activity.Id, activity.ServiceUrl, correlationVector)) |
| 229 | { |
| 230 | // Use a dedicated timeout instead of the HTTP request's cancellation token. |
| 231 | // The HTTP token fires when the client disconnects, which is expected for |
| 232 | // streaming handlers that outlive the original request. |
| 233 | using CancellationTokenSource cts = new(_processActivityTimeout); |
| 234 | try |
| 235 | { |
| 236 | CancellationToken token = Debugger.IsAttached ? CancellationToken.None : cts.Token; |
| 237 | await MiddleWare.RunPipelineAsync(this, activity, this.OnActivity, 0, token).ConfigureAwait(false); |
| 238 | } |
| 239 | catch (OperationCanceledException) when (cts.IsCancellationRequested) |
| 240 | { |
| 241 | _logger.ActivityTimedOut(_processActivityTimeout, activity.Id); |
| 242 | Telemetry.HandlerErrors.Add(1, activityTypeTag); |
| 243 | span?.SetStatus(ActivityStatusCode.Error, "timeout"); |
| 244 | } |
| 245 | catch (Exception ex) |
| 246 | { |
| 247 | _logger.ActivityProcessingError(ex, activity.Id); |
| 248 | Telemetry.HandlerErrors.Add(1, activityTypeTag); |
| 249 | span.RecordException(ex); |
| 250 | throw new BotHandlerException("Error processing activity", ex, activity); |
| 251 | } |
| 252 | finally |
| 253 | { |
| 254 | _logger.ActivityProcessingFinished(activity.Id); |
| 255 | double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; |
| 256 | Telemetry.TurnDuration.Record(elapsedMs, activityTypeTag); |
| 257 | } |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | /// <summary> |
| 262 | /// Adds the specified turn middleware to the middleware pipeline. |
| 263 | /// </summary> |
| 264 | /// <remarks> |
| 265 | /// Middleware components execute in the order they are registered. Each middleware can inspect or modify |
| 266 | /// the activity, perform side effects (such as logging), or short-circuit the pipeline by not calling |
| 267 | /// <see cref="NextTurn"/>. |
| 268 | /// <example> |
| 269 | /// <code> |
| 270 | /// bot.UseMiddleware(new MyLoggingMiddleware()); |
| 271 | /// bot.UseMiddleware(new MyAuthMiddleware()); |
| 272 | /// // Pipeline order: MyLoggingMiddleware → MyAuthMiddleware → OnActivity |
| 273 | /// </code> |
| 274 | /// </example> |
| 275 | /// </remarks> |
| 276 | /// <param name="middleware">The middleware component to add to the pipeline. Cannot be null.</param> |
| 277 | /// <returns>The <see cref="ITurnMiddleware"/> instance representing the middleware pipeline.</returns> |
| 278 | public ITurnMiddleware UseMiddleware(ITurnMiddleware middleware) |
| 279 | { |
| 280 | ArgumentNullException.ThrowIfNull(middleware); |
| 281 | MiddleWare.Use(middleware); |
| 282 | return MiddleWare; |
| 283 | } |
| 284 | |
| 285 | /// <summary> |
| 286 | /// Sends the specified activity to the conversation asynchronously. |
| 287 | /// </summary> |
| 288 | /// <remarks> |
| 289 | /// This is a convenience wrapper around <see cref="ConversationClient.SendActivityAsync"/>. The activity |
| 290 | /// must have its <see cref="CoreActivity.Conversation"/> and <see cref="CoreActivity.ServiceUrl"/> properties set. |
| 291 | /// <example> |
| 292 | /// <code> |
| 293 | /// var reply = CoreActivity.CreateBuilder() |
| 294 | /// .WithType(ActivityType.Message) |
| 295 | /// .WithConversation(incomingActivity.Conversation) |
| 296 | /// .WithServiceUrl(incomingActivity.ServiceUrl) |
| 297 | /// .WithProperty("text", "Hello from the bot!") |
| 298 | /// .Build(); |
| 299 | /// |
| 300 | /// SendActivityResponse? response = await bot.SendActivityAsync(reply, cancellationToken); |
| 301 | /// string? sentId = response?.Id; |
| 302 | /// </code> |
| 303 | /// </example> |
| 304 | /// </remarks> |
| 305 | /// <param name="activity">The activity to send. Cannot be null. Must have <see cref="CoreActivity.Conversation"/> and <see cref="CoreActivity.ServiceUrl"/> set.</param> |
| 306 | /// <param name="cancellationToken">A cancellation token that can be used to cancel the send operation.</param> |
| 307 | /// <returns>A task that represents the asynchronous operation. The task result contains a <see cref="SendActivityResponse"/> with the ID of the sent activity, or null.</returns> |
| 308 | /// <exception cref="ArgumentNullException">Thrown if <paramref name="activity"/> is null or the conversation client has not been initialized.</exception> |
| 309 | public async Task<SendActivityResponse?> SendActivityAsync(CoreActivity activity, CancellationToken cancellationToken = default) |
| 310 | { |
| 311 | ArgumentNullException.ThrowIfNull(activity); |
| 312 | ArgumentNullException.ThrowIfNull(_conversationClient, "ConversationClient not initialized"); |
| 313 | |
| 314 | return await _conversationClient.SendActivityAsync(activity, cancellationToken: cancellationToken).ConfigureAwait(false); |
| 315 | } |
| 316 | |
| 317 | /// <summary> |
| 318 | /// Gets the version of the Microsoft.Teams.Core SDK (for example, <c>"1.0.0"</c>). |
| 319 | /// </summary> |
| 320 | public static string Version => ThisAssembly.NuGetPackageVersion; |
| 321 | } |
| 322 | |