microsoft/teams.net

Public

mirrored from https://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feature/oauthflow-fixes

Branches

Tags

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

Clone

HTTPS

Download ZIP

core/src/Microsoft.Teams.Apps/Context.cs

366lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Diagnostics.CodeAnalysis;
5using Microsoft.Extensions.Logging;
6using Microsoft.Teams.Apps.Api.Clients;
7using Microsoft.Teams.Apps.OAuth;
8using Microsoft.Teams.Apps.Schema;
9using Microsoft.Teams.Apps.Schema.Entities;
10using Microsoft.Teams.Apps.State;
11using Microsoft.Teams.Core;
12
13namespace Microsoft.Teams.Apps;
14
15
16/// <summary>
17/// Context for a bot turn.
18/// </summary>
19/// <param name="botApplication">The bot application instance that owns this context.</param>
20/// <param name="activity">The incoming activity for this turn.</param>
21public class Context<TActivity>(TeamsBotApplication botApplication, TActivity activity) where TActivity : TeamsActivity
22{
23 /// <summary>
24 /// Base bot application.
25 /// </summary>
26 public TeamsBotApplication TeamsBotApplication { get; } = botApplication;
27
28 /// <summary>
29 /// Current activity.
30 /// </summary>
31 public TActivity Activity { get; } = activity;
32
33 /// <summary>
34 /// Gets the application (client) ID configured for this bot.
35 /// </summary>
36 public string AppId => TeamsBotApplication.AppId;
37
38 private ContextLogger? _log;
39
40 /// <summary>
41 /// Gets the logger for this context, providing <c>.Info()</c>, <c>.Error()</c>, <c>.Debug()</c>,
42 /// and <c>.Warn()</c> convenience methods that delegate to the underlying <see cref="ILogger"/>.
43 /// </summary>
44 public ContextLogger Log => _log ??= new ContextLogger(TeamsBotApplication.Logger);
45
46 private ApiClient? _api;
47
48 /// <summary>
49 /// Gets the <see cref="ApiClient"/> scoped to the current activity's service URL.
50 /// </summary>
51 public ApiClient Api => _api ??= TeamsBotApplication.Api.ForServiceUrl(
52 Activity.ServiceUrl ?? throw new InvalidOperationException("Activity.ServiceUrl is required to use the Api client."));
53
54 // ==================== Turn State ====================
55
56 private TurnStateContainer? _state;
57
58 /// <summary>
59 /// Gets the per-turn state container with <see cref="TurnStateContainer.ConversationState"/>
60 /// and <see cref="TurnStateContainer.UserState"/> scopes.
61 /// </summary>
62 /// <exception cref="InvalidOperationException">Thrown when state management is not configured.</exception>
63 public TurnStateContainer State
64 {
65 get => _state ?? throw new InvalidOperationException(
66 "State is not available. Call UseState() during service registration, and if using a custom TeamsBotApplication make sure you pass a TurnStateLoader instance.");
67 internal set => _state = value;
68 }
69
70 /// <summary>
71 /// Returns true if state has been loaded for this turn.
72 /// </summary>
73 public bool HasState => _state is not null;
74
75 /// <summary>
76 /// Creates a copy of this context, preserving state if available.
77 /// </summary>
78 internal Context<TActivity> CreateDerivedContext()
79 {
80 Context<TActivity> derived = new(TeamsBotApplication, Activity);
81 if (HasState)
82 {
83 derived.State = State;
84 }
85 return derived;
86 }
87
88 /// <summary>
89 /// Creates a new context for a different activity type, preserving state if available.
90 /// </summary>
91 internal Context<TNew> CreateDerivedContext<TNew>(TNew activity) where TNew : TeamsActivity
92 {
93 Context<TNew> derived = new(TeamsBotApplication, activity);
94 if (HasState)
95 {
96 derived.State = State;
97 }
98 return derived;
99 }
100
101 // ==================== Convenience Send/Reply/Typing ====================
102
103 /// <summary>
104 /// Sends a text message to the conversation.
105 /// </summary>
106 /// <param name="text">The text to send.</param>
107 /// <param name="cancellationToken">A cancellation token.</param>
108 /// <returns>The response from the send operation.</returns>
109 public Task<SendActivityResponse?> SendAsync(string text, CancellationToken cancellationToken = default)
110 => SendActivityAsync(text, cancellationToken);
111
112 /// <summary>
113 /// Sends an activity to the conversation.
114 /// </summary>
115 /// <param name="activity">The activity to send.</param>
116 /// <param name="cancellationToken">A cancellation token.</param>
117 /// <returns>The response from the send operation.</returns>
118 public Task<SendActivityResponse?> SendAsync(TeamsActivity activity, CancellationToken cancellationToken = default)
119 => SendActivityAsync(activity, cancellationToken);
120
121 /// <summary>
122 /// Sends a text message as a threaded reply to the current activity. When the inbound activity
123 /// has an id, the response auto-quotes it (rendered as a quote bubble above the response in Teams);
124 /// otherwise sends without quoting.
125 /// </summary>
126 /// <param name="text">The text to send.</param>
127 /// <param name="cancellationToken">A cancellation token.</param>
128 /// <returns>The response from the send operation.</returns>
129 public Task<SendActivityResponse?> ReplyAsync(string text, CancellationToken cancellationToken = default)
130 => ReplyAsync(new MessageActivity(text), cancellationToken);
131
132 /// <summary>
133 /// Sends an activity to the conversation. When the inbound activity has an id, the response
134 /// auto-quotes it (rendered as a quote bubble above the response in Teams). Otherwise sends
135 /// without quoting. To send without quoting unconditionally, use <see cref="Send(TeamsActivity, CancellationToken)"/>.
136 /// </summary>
137 /// <param name="activity">The activity to send.</param>
138 /// <param name="cancellationToken">A cancellation token.</param>
139 /// <returns>The response from the send operation.</returns>
140 public Task<SendActivityResponse?> ReplyAsync(TeamsActivity activity, CancellationToken cancellationToken = default)
141 {
142 ArgumentNullException.ThrowIfNull(activity);
143 if (!string.IsNullOrWhiteSpace(Activity.Id))
144 {
145 return Quote(Activity.Id, activity, cancellationToken);
146 }
147
148 return SendActivityAsync(activity, cancellationToken);
149 }
150
151 /// <summary>
152 /// Sends a typing indicator to the conversation.
153 /// </summary>
154 /// <param name="cancellationToken">A cancellation token.</param>
155 /// <returns>The response from the send operation.</returns>
156 public Task<SendActivityResponse?> TypingAsync(CancellationToken cancellationToken = default)
157 => SendTypingActivityAsync(cancellationToken);
158
159 /// <summary>
160 /// Send a message to the conversation with a quoted message reference prepended to the text.
161 /// Teams renders the quoted message as a preview bubble above the response text.
162 /// </summary>
163 /// <param name="messageId">The ID of the message to quote.</param>
164 /// <param name="text">The response text, appended to the quoted message placeholder.</param>
165 /// <param name="cancellationToken">Optional cancellation token.</param>
166 /// <returns>The response from sending the activity.</returns>
167 [Experimental("ExperimentalTeamsQuotedReplies")]
168 public Task<SendActivityResponse?> Quote(string messageId, string text, CancellationToken cancellationToken = default)
169 => Quote(messageId, new MessageActivity(text), cancellationToken);
170
171 /// <summary>
172 /// Send a message to the conversation with a quoted message reference prepended to the text.
173 /// Teams renders the quoted message as a preview bubble above the response text.
174 /// </summary>
175 /// <param name="messageId">The ID of the message to quote.</param>
176 /// <param name="activity">The activity to send. For <see cref="MessageActivity"/>, a quote placeholder for messageId is prepended to its text. Other activity types are sent as-is without quoting.</param>
177 /// <param name="cancellationToken">Optional cancellation token.</param>
178 /// <returns>The response from sending the activity.</returns>
179 [Experimental("ExperimentalTeamsQuotedReplies")]
180 public Task<SendActivityResponse?> Quote(string messageId, TeamsActivity activity, CancellationToken cancellationToken = default)
181 {
182 ArgumentNullException.ThrowIfNull(activity);
183 ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
184 if (activity is MessageActivity message)
185 {
186 message.PrependQuote(messageId);
187 }
188 return SendActivityAsync(activity, cancellationToken);
189 }
190
191 /// <inheritdoc cref="SendAsync(string, CancellationToken)"/>
192 public Task<SendActivityResponse?> Send(string text, CancellationToken cancellationToken = default)
193 => SendAsync(text, cancellationToken);
194
195 /// <inheritdoc cref="SendAsync(TeamsActivity, CancellationToken)"/>
196 public Task<SendActivityResponse?> Send(TeamsActivity activity, CancellationToken cancellationToken = default)
197 => SendAsync(activity, cancellationToken);
198
199 /// <inheritdoc cref="ReplyAsync(string, CancellationToken)"/>
200 public Task<SendActivityResponse?> Reply(string text, CancellationToken cancellationToken = default)
201 => ReplyAsync(text, cancellationToken);
202
203 /// <inheritdoc cref="ReplyAsync(TeamsActivity, CancellationToken)"/>
204 public Task<SendActivityResponse?> Reply(TeamsActivity activity, CancellationToken cancellationToken = default)
205 => ReplyAsync(activity, cancellationToken);
206
207 /// <inheritdoc cref="TypingAsync(CancellationToken)"/>
208 public Task<SendActivityResponse?> Typing(CancellationToken cancellationToken = default)
209 => TypingAsync(cancellationToken);
210
211 // ==================== Core Send Methods ====================
212
213 /// <summary>
214 /// Sends a message activity as a reply.
215 /// </summary>
216 /// <param name="text">The text to send.</param>
217 /// <param name="cancellationToken">A cancellation token.</param>
218 /// <returns>The response from the send operation.</returns>
219 public Task<SendActivityResponse?> SendActivityAsync(string text, CancellationToken cancellationToken = default)
220 => SendActivityAsync(new MessageActivity(text) { TextFormat = TextFormats.Plain }, cancellationToken);
221
222 /// <summary>
223 /// Sends an activity to the conversation.
224 /// </summary>
225 /// <param name="activity">The activity to send.</param>
226 /// <param name="cancellationToken">A cancellation token.</param>
227 /// <returns>The response from the send operation.</returns>
228 public Task<SendActivityResponse?> SendActivityAsync(TeamsActivity activity, CancellationToken cancellationToken = default)
229 {
230 ArgumentNullException.ThrowIfNull(activity);
231
232 bool isTargeted = activity.Recipient?.IsTargeted == true;
233
234 if (isTargeted && Activity.Conversation?.ConversationType == ConversationType.Personal)
235 {
236 throw new InvalidOperationException(
237 "Targeted messages are not supported in personal (1:1) chats.");
238 }
239
240 if (activity.Type == TeamsActivityType.Message
241 && Activity.Recipient?.IsTargeted == true
242 && Activity.Id is not null)
243 {
244 TargetedMessageInfoEntityExtensions.AddToActivity(activity, Activity.Id);
245 }
246
247 TeamsActivity reply = new TeamsActivityBuilder(activity)
248 .WithConversationReference(Activity)
249 .Build();
250 return TeamsBotApplication.SendActivityAsync(reply, cancellationToken: cancellationToken);
251 }
252
253 /// <summary>
254 /// Sends a typing activity to the conversation asynchronously.
255 /// </summary>
256 /// <param name="cancellationToken">A cancellation token.</param>
257 /// <returns>The response from the send operation.</returns>
258 public Task<SendActivityResponse?> SendTypingActivityAsync(CancellationToken cancellationToken = default)
259 {
260 TeamsActivity reply = new TeamsActivityBuilder()
261 .WithType(TeamsActivityType.Typing)
262 .WithConversationReference(Activity)
263 .Build();
264 return TeamsBotApplication.SendActivityAsync(reply, cancellationToken: cancellationToken);
265 }
266
267 // ==================== OAuth Sign-In ====================
268
269 /// <summary>
270 /// Trigger user OAuth sign-in flow for the activity sender.
271 /// Attempts silent token acquisition first; if no token is cached, sends an OAuthCard.
272 /// </summary>
273 /// <param name="options">OAuth options including connection name and card text.</param>
274 /// <param name="cancellationToken">A cancellation token.</param>
275 /// <returns>The existing user token if found, or null if the sign-in flow was initiated.</returns>
276 public Task<string?> SignInAsync(OAuthOptions? options = null, CancellationToken cancellationToken = default)
277 {
278 OAuthFlow flow = ResolveOAuthFlow(options?.ConnectionName);
279 return flow.SignInAsync(this, options, cancellationToken);
280 }
281
282 /// <summary>
283 /// Sign the user out, revoking their token from the Bot Framework Token Store.
284 /// </summary>
285 /// <param name="connectionName">The connection name to sign out from. If null, uses the default registered connection.</param>
286 /// <param name="cancellationToken">A cancellation token.</param>
287 public Task SignOutAsync(string? connectionName = null, CancellationToken cancellationToken = default)
288 {
289 OAuthFlow flow = ResolveOAuthFlow(connectionName);
290 return flow.SignOutAsync(this, cancellationToken);
291 }
292
293 /// <inheritdoc cref="SignInAsync(OAuthOptions?, CancellationToken)"/>
294 public Task<string?> SignIn(OAuthOptions? options = null, CancellationToken cancellationToken = default)
295 => SignInAsync(options, cancellationToken);
296
297 /// <inheritdoc cref="SignOutAsync(string?, CancellationToken)"/>
298 public Task SignOut(string? connectionName = null, CancellationToken cancellationToken = default)
299 => SignOutAsync(connectionName, cancellationToken);
300
301 /// <summary>
302 /// Whether the activity sender has a valid cached token.
303 /// </summary>
304 /// <remarks>
305 /// This property is no longer supported because it used sync-over-async
306 /// (<c>.GetAwaiter().GetResult()</c>) to call the remote Token Service,
307 /// which causes thread-pool starvation under load.
308 /// Use <see cref="IsSignedInAsync"/> instead.
309 /// </remarks>
310 [Obsolete("Use IsSignedInAsync() instead. This property always throws NotSupportedException.")]
311 public bool IsSignedIn
312 => throw new NotSupportedException(
313 "IsSignedIn is no longer supported because it blocks the calling thread. Use 'await IsSignedInAsync()' instead.");
314
315 /// <summary>
316 /// Check whether the user has a valid cached token for a given OAuth connection.
317 /// </summary>
318 /// <param name="connectionName">The connection name to check. If null, uses the single registered connection.</param>
319 /// <param name="cancellationToken">A cancellation token.</param>
320 /// <returns>True if the user has a valid token; false otherwise.</returns>
321 public Task<bool> IsSignedInAsync(string? connectionName = null, CancellationToken cancellationToken = default)
322 {
323 OAuthFlow flow = ResolveOAuthFlow(connectionName);
324 return flow.IsSignedInAsync(this, cancellationToken);
325 }
326
327 /// <summary>
328 /// Get the token status for all configured OAuth connections.
329 /// Returns every connection registered on the bot, so the developer
330 /// never needs to enumerate connection names manually.
331 /// </summary>
332 /// <param name="cancellationToken">A cancellation token.</param>
333 /// <returns>A list of token status results for all configured connections.</returns>
334 public Task<IList<GetTokenStatusResult>> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
335 {
336 OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry
337 ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow(connectionName) on the TeamsBotApplication first.");
338
339 // Use any flow -- GetConnectionStatusAsync returns all connections regardless
340 OAuthFlow flow = registry.ResolveSingle()
341 ?? registry.GetAllFlows().First();
342
343 return flow.GetConnectionStatusAsync(this, cancellationToken);
344 }
345
346 private OAuthFlow ResolveOAuthFlow(string? connectionName)
347 {
348 OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry
349 ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow(connectionName) on the TeamsBotApplication first.");
350
351 if (connectionName is not null)
352 {
353 OAuthFlow? flow = registry.Resolve(connectionName);
354 if (flow is not null) return flow;
355
356 string registered = string.Join(", ", registry.GetRegisteredConnectionNames().Select(n => $"'{n}'"));
357 throw new InvalidOperationException(
358 $"No OAuthFlow registered for connection '{connectionName}'. " +
359 $"Registered connections: {(registered.Length > 0 ? registered : "(none)")}.");
360 }
361
362 return registry.ResolveSingle()
363 ?? throw new InvalidOperationException(
364 "Multiple OAuthFlow instances registered. Specify a connection name in OAuthOptions or SignOut(connectionName).");
365 }
366}
367