// 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).");
}
}