microsoft/teams.net
Publicmirrored from https://github.com/microsoft/teams.netAvailable
core/src/Microsoft.Teams.Apps/Context.cs
366lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | using System.Diagnostics.CodeAnalysis; |
| 5 | using Microsoft.Extensions.Logging; |
| 6 | using Microsoft.Teams.Apps.Api.Clients; |
| 7 | using Microsoft.Teams.Apps.OAuth; |
| 8 | using Microsoft.Teams.Apps.Schema; |
| 9 | using Microsoft.Teams.Apps.Schema.Entities; |
| 10 | using Microsoft.Teams.Apps.State; |
| 11 | using Microsoft.Teams.Core; |
| 12 | |
| 13 | namespace 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> |
| 21 | public 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 | |