// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.Extensions.Logging; using Microsoft.Teams.Apps.Api.Clients; using Microsoft.Teams.Apps.OAuth; using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Apps.Schema.Entities; using Microsoft.Teams.Apps.State; using Microsoft.Teams.Core; namespace Microsoft.Teams.Apps; /// /// Context for a bot turn. /// /// The bot application instance that owns this context. /// The incoming activity for this turn. public class Context(TeamsBotApplication botApplication, TActivity activity) where TActivity : TeamsActivity { /// /// Base bot application. /// public TeamsBotApplication TeamsBotApplication { get; } = botApplication; /// /// Current activity. /// public TActivity Activity { get; } = activity; /// /// Gets the application (client) ID configured for this bot. /// public string AppId => TeamsBotApplication.AppId; private ContextLogger? _log; /// /// Gets the backward-compatible logger for this context, providing .Info(), .Error(), /// .Debug(), and .Warn() convenience methods that delegate to the underlying . /// [Obsolete("Use a standard Microsoft.Extensions.Logging ILogger obtained via dependency injection instead.")] public ContextLogger Log => _log ??= new ContextLogger(TeamsBotApplication.Logger); private ApiClient? _api; /// /// Gets the scoped to the current activity's service URL. /// public ApiClient Api => _api ??= TeamsBotApplication.Api.ForServiceUrl( Activity.ServiceUrl ?? throw new InvalidOperationException("Activity.ServiceUrl is required to use the Api client.")); // ==================== Turn State ==================== private TurnStateContainer? _state; /// /// Gets the per-turn state container with /// and scopes. /// /// Thrown when state management is not configured. public TurnStateContainer State { get => _state ?? throw new InvalidOperationException( "State is not available. Call UseState() during service registration, and if using a custom TeamsBotApplication make sure you pass a TurnStateLoader instance."); internal set => _state = value; } /// /// Returns true if state has been loaded for this turn. /// public bool HasState => _state is not null; /// /// Creates a copy of this context, preserving state if available. /// internal Context CreateDerivedContext() { Context derived = new(TeamsBotApplication, Activity); if (HasState) { derived.State = State; } return derived; } /// /// Creates a new context for a different activity type, preserving state if available. /// internal Context CreateDerivedContext(TNew activity) where TNew : TeamsActivity { Context derived = new(TeamsBotApplication, activity); if (HasState) { derived.State = State; } return derived; } // ==================== Convenience Send/Reply/Typing ==================== /// /// Sends a text message to the conversation. /// /// The text to send. /// A cancellation token. /// The response from the send operation. public Task SendAsync(string text, CancellationToken cancellationToken = default) => SendActivityAsync(text, cancellationToken); /// /// Sends an activity to the conversation. /// /// The activity to send. /// A cancellation token. /// The response from the send operation. public Task SendAsync(TeamsActivity activity, CancellationToken cancellationToken = default) => SendActivityAsync(activity, cancellationToken); /// /// Sends a text message as a threaded reply to the current activity. When the inbound activity /// has an id, the response auto-quotes it (rendered as a quote bubble above the response in Teams); /// otherwise sends without quoting. /// /// The text to send. /// A cancellation token. /// The response from the send operation. public Task ReplyAsync(string text, CancellationToken cancellationToken = default) => ReplyAsync(new MessageActivity(text), cancellationToken); /// /// Sends an activity to the conversation. When the inbound activity has an id, the response /// auto-quotes it (rendered as a quote bubble above the response in Teams). Otherwise sends /// without quoting. To send without quoting unconditionally, use . /// /// The activity to send. /// A cancellation token. /// The response from the send operation. public Task ReplyAsync(TeamsActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); if (!string.IsNullOrWhiteSpace(Activity.Id)) { return QuoteAsync(Activity.Id, activity, cancellationToken); } return SendActivityAsync(activity, cancellationToken); } /// /// Sends a typing indicator to the conversation. /// /// A cancellation token. /// The response from the send operation. public Task TypingAsync(CancellationToken cancellationToken = default) => SendTypingActivityAsync(cancellationToken); /// /// Send a message to the conversation with a quoted message reference prepended to the text. /// Teams renders the quoted message as a preview bubble above the response text. /// /// The ID of the message to quote. /// The response text, appended to the quoted message placeholder. /// Optional cancellation token. /// The response from sending the activity. public Task QuoteAsync(string messageId, string text, CancellationToken cancellationToken = default) => QuoteAsync(messageId, new MessageActivity(text), cancellationToken); /// /// Send a message to the conversation with a quoted message reference prepended to the text. /// Teams renders the quoted message as a preview bubble above the response text. /// /// The ID of the message to quote. /// The activity to send. For , a quote placeholder for messageId is prepended to its text. Other activity types are sent as-is without quoting. /// Optional cancellation token. /// The response from sending the activity. public Task QuoteAsync(string messageId, TeamsActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); ArgumentException.ThrowIfNullOrWhiteSpace(messageId); if (activity is MessageActivity message) { message.PrependQuote(messageId); } return SendActivityAsync(activity, cancellationToken); } /// [Obsolete("Use SendAsync instead.")] public Task Send(string text, CancellationToken cancellationToken = default) => SendAsync(text, cancellationToken); /// [Obsolete("Use SendAsync instead.")] public Task Send(TeamsActivity activity, CancellationToken cancellationToken = default) => SendAsync(activity, cancellationToken); /// [Obsolete("Use ReplyAsync instead.")] public Task Reply(string text, CancellationToken cancellationToken = default) => ReplyAsync(text, cancellationToken); /// [Obsolete("Use ReplyAsync instead.")] public Task Reply(TeamsActivity activity, CancellationToken cancellationToken = default) => ReplyAsync(activity, cancellationToken); /// [Obsolete("Use TypingAsync instead.")] public Task Typing(CancellationToken cancellationToken = default) => TypingAsync(cancellationToken); /// [Obsolete("Use QuoteAsync instead.")] public Task Quote(string messageId, string text, CancellationToken cancellationToken = default) => QuoteAsync(messageId, text, cancellationToken); /// [Obsolete("Use QuoteAsync instead.")] public Task Quote(string messageId, TeamsActivity activity, CancellationToken cancellationToken = default) => QuoteAsync(messageId, activity, cancellationToken); // ==================== Core Send Methods ==================== /// /// Sends a message activity as a reply. /// /// The text to send. /// A cancellation token. /// The response from the send operation. public Task SendActivityAsync(string text, CancellationToken cancellationToken = default) => SendActivityAsync(new MessageActivity(text) { TextFormat = TextFormats.Plain }, cancellationToken); /// /// Sends an activity to the conversation. /// /// The activity to send. /// A cancellation token. /// The response from the send operation. public Task SendActivityAsync(TeamsActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); bool isTargeted = activity.Recipient?.IsTargeted == true; if (isTargeted && Activity.Conversation?.ConversationType == ConversationTypes.Personal) { throw new InvalidOperationException( "Targeted messages are not supported in personal (1:1) chats."); } if (activity.Type == TeamsActivityTypes.Message && Activity.Recipient?.IsTargeted == true && Activity.Id is not null) { TargetedMessageInfoEntityExtensions.AddToActivity(activity, Activity.Id); } TeamsActivity reply = new TeamsActivityBuilder(activity) .WithConversationReference(Activity) .Build(); return TeamsBotApplication.SendActivityAsync(reply, cancellationToken: cancellationToken); } /// /// Sends a typing activity to the conversation asynchronously. /// /// A cancellation token. /// The response from the send operation. public Task SendTypingActivityAsync(CancellationToken cancellationToken = default) { TeamsActivity reply = new TeamsActivityBuilder() .WithType(TeamsActivityTypes.Typing) .WithConversationReference(Activity) .Build(); return TeamsBotApplication.SendActivityAsync(reply, cancellationToken: cancellationToken); } // ==================== OAuth Sign-In ==================== /// /// Trigger user OAuth sign-in flow for the activity sender. /// Attempts silent token acquisition first; if no token is cached, sends an OAuthCard. /// /// OAuth options including connection name and card text. /// A cancellation token. /// The existing user token if found, or null if the sign-in flow was initiated. [Obsolete("Use the OAuthFlow directly: TeamsBotApplication.GetOAuthFlow(connectionName).SignInAsync(context, ...).")] public Task SignInAsync(OAuthOptions? options = null, CancellationToken cancellationToken = default) { OAuthFlow flow = ResolveOAuthFlow(options?.ConnectionName); return flow.SignInAsync(this, options, cancellationToken); } /// /// Sign the user out, revoking their token from the Bot Framework Token Store. /// /// The connection name to sign out from. If null, uses the default registered connection. /// A cancellation token. [Obsolete("Use the OAuthFlow directly: TeamsBotApplication.GetOAuthFlow(connectionName).SignOutAsync(context, ...).")] public Task SignOutAsync(string? connectionName = null, CancellationToken cancellationToken = default) { OAuthFlow flow = ResolveOAuthFlow(connectionName); return flow.SignOutAsync(this, cancellationToken); } /// [Obsolete("Use the OAuthFlow directly: TeamsBotApplication.GetOAuthFlow(connectionName).SignInAsync(context, ...).")] public Task SignIn(OAuthOptions? options = null, CancellationToken cancellationToken = default) #pragma warning disable CS0618 // delegates to the obsolete SignInAsync; both are deprecated in favor of the flow. => SignInAsync(options, cancellationToken); #pragma warning restore CS0618 /// [Obsolete("Use the OAuthFlow directly: TeamsBotApplication.GetOAuthFlow(connectionName).SignOutAsync(context, ...).")] public Task SignOut(string? connectionName = null, CancellationToken cancellationToken = default) #pragma warning disable CS0618 // delegates to the obsolete SignOutAsync; both are deprecated in favor of the flow. => SignOutAsync(connectionName, cancellationToken); #pragma warning restore CS0618 /// /// Check whether the user has a valid cached token for a given OAuth connection. /// /// The connection name to check. If null, uses the single registered connection. /// A cancellation token. /// True if the user has a valid token; false otherwise. [Obsolete("Use the OAuthFlow directly: TeamsBotApplication.GetOAuthFlow(connectionName).IsSignedInAsync(context, ...).")] public Task IsSignedInAsync(string? connectionName = null, CancellationToken cancellationToken = default) { OAuthFlow flow = ResolveOAuthFlow(connectionName); return flow.IsSignedInAsync(this, cancellationToken); } /// /// Get the token status for all configured OAuth connections. /// Returns every connection registered on the bot, so the developer /// never needs to enumerate connection names manually. /// /// A cancellation token. /// A list of token status results for all configured connections. [Obsolete("Use the OAuthFlow directly: TeamsBotApplication.GetOAuthFlow(connectionName).GetConnectionStatusAsync(context, ...).")] public Task> GetConnectionStatusAsync(CancellationToken cancellationToken = default) { OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow(connectionName) on the TeamsBotApplication first."); // Use any flow -- GetConnectionStatusAsync returns all connections regardless OAuthFlow flow = registry.ResolveSingle() ?? registry.GetAllFlows().First(); return flow.GetConnectionStatusAsync(this, cancellationToken); } private OAuthFlow ResolveOAuthFlow(string? connectionName) { OAuthFlowRegistry registry = TeamsBotApplication.OAuthRegistry ?? throw new InvalidOperationException("No OAuthFlow registered. Call AddOAuthFlow(connectionName) on the TeamsBotApplication first."); if (connectionName is not null) { OAuthFlow? flow = registry.Resolve(connectionName); if (flow is not null) return flow; string registered = string.Join(", ", registry.GetRegisteredConnectionNames().Select(n => $"'{n}'")); throw new InvalidOperationException( $"No OAuthFlow registered for connection '{connectionName}'. " + $"Registered connections: {(registered.Length > 0 ? registered : "(none)")}."); } return registry.ResolveSingle() ?? throw new InvalidOperationException( "Multiple OAuthFlow instances registered. Specify a connection name in OAuthOptions or SignOut(connectionName)."); } }