microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v2.0.7

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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