microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/a365-mcp

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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