microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
kavin/agents-sdk-interop

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/src/Microsoft.Teams.Core/BotApplication.cs

321lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Diagnostics;
5using Microsoft.AspNetCore.Http;
6using Microsoft.Extensions.Logging;
7using Microsoft.Extensions.Logging.Abstractions;
8using Microsoft.Teams.Core.Diagnostics;
9using Microsoft.Teams.Core.Hosting;
10using Microsoft.Teams.Core.Schema;
11
12namespace 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&lt;MyBot&gt; 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>
77public 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