// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using Microsoft.Teams.Api;
using Microsoft.Teams.Api.Activities;
using Microsoft.Teams.Api.Auth;
using Microsoft.Teams.Api.Clients;
using Microsoft.Teams.Apps.Activities.Invokes;
using Microsoft.Teams.Apps.Events;
using Microsoft.Teams.Apps.Plugins;
using Microsoft.Teams.Common.Http;
using Microsoft.Teams.Common.Logging;
using Microsoft.Teams.Common.Storage;
namespace Microsoft.Teams.Apps;
public partial class App
{
public static AppBuilder Builder(AppOptions? options = null) => new(options);
///
/// the apps id
///
public string? Id => Token?.AppId;
///
/// the apps name
///
public string? Name => Token?.AppDisplayName;
public Status? Status { get; internal set; }
public ILogger Logger { get; }
public IStorage Storage { get; }
public ApiClient Api { get; internal set; }
public IHttpClient Client { get; }
public IHttpCredentials? Credentials { get; }
public IToken? Token { get; internal set; }
public OAuthSettings OAuth { get; internal set; }
///
/// When true, performs a per-activity user OAuth token lookup to populate
/// IContext.IsSignedIn / IContext.UserGraphToken . Set to false to
/// skip the call when SSO is not configured. Defaults to true.
///
public bool AutoUserTokenLookup { get; internal set; }
internal IHttpClient TokenClient { get; set; }
internal IServiceProvider? Provider { get; set; }
internal IContainer Container { get; set; }
internal string UserAgent
{
get
{
var version = ThisAssembly.NuGetPackageVersion ?? "0.0.0";
return $"teams.net[apps]/{version}";
}
}
public App(AppOptions? options = null)
{
var cloud = options?.Cloud ?? CloudEnvironment.Public;
Logger = options?.Logger ?? new ConsoleLogger();
Storage = options?.Storage ?? new LocalStorage();
Credentials = options?.Credentials;
Plugins = options?.Plugins ?? [];
OAuth = options?.OAuth ?? new OAuthSettings();
AutoUserTokenLookup = options?.AutoUserTokenLookup ?? true;
Provider = options?.Provider;
TokenClient = new Common.Http.HttpClient();
Client = options?.Client ?? options?.ClientFactory?.CreateClient() ?? new Common.Http.HttpClient();
Client.Options.AddUserAgent(UserAgent);
Client.Options.TokenFactory ??= () =>
{
if (Credentials is not null)
{
if (Token is null)
{
var res = Api!.Bots.Token.GetAsync(Credentials, TokenClient)
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
Token = new JsonWebToken(res.AccessToken);
}
if (Token.IsExpired)
{
var res = Credentials.Resolve(TokenClient, [.. Token.Scopes.DefaultIfEmpty(Api!.Bots.Token.ActiveBotScope)])
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
Token = new JsonWebToken(res.AccessToken);
}
}
return Token;
};
Api = new ApiClient("https://smba.trafficmanager.net/teams/", Client);
Api.Bots.Token.ActiveBotScope = cloud.BotScope;
Api.Bots.Token.ActiveGraphScope = cloud.GraphScope;
Api.Bots.SignIn.TokenServiceUrl = cloud.TokenServiceUrl;
Api.Users.Token.TokenServiceUrl = cloud.TokenServiceUrl;
Container = new Container();
Container.Register(Logger);
Container.Register(Storage);
Container.Register(Client);
Container.Register(Api);
Container.Register(new FactoryProvider(() => Credentials));
Container.Register("AppId", new FactoryProvider(() => Id));
Container.Register("AppName", new FactoryProvider(() => Name));
Container.Register("Token", new FactoryProvider(() => Token));
this.OnTokenExchange(OnTokenExchangeActivity);
this.OnVerifyState(OnVerifyStateActivity);
this.OnSignInFailure(OnSignInFailureActivity);
this.OnError(OnErrorEvent);
this.OnActivitySent(OnActivitySentEvent);
this.OnActivityResponse(OnActivityResponseEvent);
Events.On(EventType.Activity, (plugin, @event, token) =>
{
return OnActivityEvent((ISenderPlugin)plugin, (ActivityEvent)@event, token);
});
Status = Apps.Status.Ready;
}
///
/// start the app
///
public async Task Start(CancellationToken cancellationToken = default)
{
try
{
foreach (var plugin in Plugins)
{
Inject(plugin);
}
if (Credentials is not null)
{
try
{
var res = await Api.Bots.Token.GetAsync(Credentials, TokenClient).ConfigureAwait(false);
Token = new JsonWebToken(res.AccessToken);
}
catch (Exception ex)
{
Logger.Error("Failed to get bot token on app startup.", ex);
}
}
Logger.Debug(Id);
Logger.Debug(Name);
foreach (var plugin in Plugins)
{
await plugin.OnInit(this, cancellationToken).ConfigureAwait(false);
}
foreach (var plugin in Plugins)
{
await plugin.OnStart(this, cancellationToken).ConfigureAwait(false);
}
Status = Apps.Status.Started;
}
catch (Exception ex)
{
Status = Apps.Status.Stopped;
await Events.Emit(
null!,
EventType.Error,
new ErrorEvent() { Exception = ex }
).ConfigureAwait(false);
}
}
///
/// send an activity proactively to a conversation.
/// Sends to the exact conversation ID provided. For channel threads,
/// the conversation ID must include ;messageid= -- use
/// to construct it, or use
/// which handles this automatically.
///
public async Task Send(string conversationId, T activity, ConversationType? conversationType = null, string? serviceUrl = null, CancellationToken cancellationToken = default) where T : IActivity
{
if (Id is null)
{
throw new InvalidOperationException("app not started");
}
var reference = new ConversationReference()
{
ChannelId = ChannelId.MsTeams,
ServiceUrl = serviceUrl ?? Api.ServiceUrl,
Bot = new()
{
Id = Id,
Name = Name,
},
Conversation = new()
{
Id = conversationId,
Type = conversationType
}
};
var sender = Plugins.Where(plugin => plugin is ISenderPlugin).Select(plugin => plugin as ISenderPlugin).First();
if (sender is null)
{
throw new Exception("no plugin that can send activities was found");
}
var res = await sender.Send(activity, reference, cancellationToken).ConfigureAwait(false);
await Events.Emit(
sender,
EventType.ActivitySent,
new ActivitySentEvent() { Activity = res },
cancellationToken
).ConfigureAwait(false);
return res;
}
///
/// send a message activity to the conversation
///
/// the text to send
public async Task Send(string conversationId, string text, ConversationType? conversationType = null, string? serviceUrl = null, CancellationToken cancellationToken = default)
{
return await Send(conversationId, new MessageActivity(text), conversationType, serviceUrl, cancellationToken).ConfigureAwait(false);
}
///
/// send a message activity with a card attachment
///
/// the card to send as an attachment
public async Task Send(string conversationId, Cards.AdaptiveCard card, ConversationType? conversationType = null, string? serviceUrl = null, CancellationToken cancellationToken = default)
{
return await Send(conversationId, new MessageActivity().AddAttachment(card), conversationType, serviceUrl, cancellationToken).ConfigureAwait(false);
}
///
/// send an activity proactively to a conversation, optionally as a threaded reply.
/// Constructs a threaded conversation ID from the conversation ID
/// and message ID via ,
/// then sends to that thread. The service determines whether threading is
/// supported for the given conversation type.
///
/// the conversation ID
/// the thread root message ID
/// the activity to send
/// optional cancellation token
public Task Reply(string conversationId, string messageId, T activity, CancellationToken cancellationToken = default) where T : IActivity
{
return Send(Conversation.ToThreadedConversationId(conversationId, messageId), activity, cancellationToken: cancellationToken);
}
///
/// send an activity proactively to a conversation.
/// Sends to the exact conversation ID provided - threaded if
/// it contains ;messageid= , flat otherwise.
///
/// the conversation to send to
/// the activity to send
/// optional cancellation token
public Task Reply(string conversationId, T activity, CancellationToken cancellationToken = default) where T : IActivity
{
return Send(conversationId, activity, cancellationToken: cancellationToken);
}
///
/// send a message proactively to a thread
///
public Task Reply(string conversationId, string messageId, string text, CancellationToken cancellationToken = default)
{
return Reply(conversationId, messageId, new MessageActivity(text), cancellationToken);
}
///
/// send a message proactively to a conversation
///
public Task Reply(string conversationId, string text, CancellationToken cancellationToken = default)
{
return Reply(conversationId, new MessageActivity(text), cancellationToken);
}
///
/// send a card proactively to a thread
///
public Task Reply(string conversationId, string messageId, Cards.AdaptiveCard card, CancellationToken cancellationToken = default)
{
return Reply(conversationId, messageId, new MessageActivity().AddAttachment(card), cancellationToken);
}
///
/// send a card proactively to a conversation
///
public Task Reply(string conversationId, Cards.AdaptiveCard card, CancellationToken cancellationToken = default)
{
return Reply(conversationId, new MessageActivity().AddAttachment(card), cancellationToken);
}
///
/// process an activity
///
/// the plugin to use
/// the request token
/// the inbound activity
/// the cancellation token
public async Task Process(ISenderPlugin sender, IToken token, IActivity activity, IDictionary? extra = null, CancellationToken cancellationToken = default)
{
return await Process(sender, new()
{
Token = token,
Activity = activity,
Extra = extra
}, cancellationToken).ConfigureAwait(false);
}
///
/// process an activity
///
/// the plugin to use
/// the request token
/// the inbound activity
/// the cancellation token
///
public Task Process(string sender, IToken token, IActivity activity, IDictionary? extra = null, CancellationToken cancellationToken = default)
{
var plugin = ((ISenderPlugin?)GetPlugin(sender)) ?? throw new Exception($"sender plugin '{sender}' not found");
return Process(plugin, token, activity, extra, cancellationToken);
}
///
/// process an activity
///
/// the request token
/// the inbound activity
/// the cancellation token
///
public Task Process(IToken token, IActivity activity, IDictionary? extra = null, CancellationToken cancellationToken = default) where TPlugin : ISenderPlugin
{
var plugin = GetPlugin() ?? throw new Exception($"sender plugin '{typeof(TPlugin).Name}' not found");
return Process(plugin, token, activity, extra, cancellationToken);
}
///
/// process an activity
///
/// the plugin to use
/// the activity event
/// the cancellation token
private async Task Process(ISenderPlugin sender, ActivityEvent @event, CancellationToken cancellationToken = default)
{
var start = DateTime.UtcNow;
var routes = Router.Select(@event.Activity);
JsonWebToken? userToken = null;
var api = new ApiClient(Api, cancellationToken);
if (AutoUserTokenLookup)
{
try
{
var tokenResponse = await api.Users.Token.GetAsync(new()
{
UserId = @event.Activity.From.Id,
ChannelId = @event.Activity.ChannelId,
ConnectionName = OAuth.DefaultConnectionName
}, cancellationToken).ConfigureAwait(false);
userToken = new JsonWebToken(tokenResponse);
}
catch (OperationCanceledException)
{
throw;
}
catch { }
}
var path = @event.Activity.GetPath();
Logger.Debug(path);
var serviceUrl = @event.Activity.ServiceUrl ?? @event.Token.ServiceUrl;
var reference = new ConversationReference()
{
ServiceUrl = serviceUrl,
ChannelId = @event.Activity.ChannelId,
Bot = @event.Activity.Recipient,
User = @event.Activity.From,
Locale = @event.Activity.Locale,
Conversation = @event.Activity.Conversation,
};
object? data = null;
var i = -1;
async Task Next(IContext context)
{
if (i + 1 == routes.Count) return data;
i++;
var res = await routes[i].Invoke(context).ConfigureAwait(false);
if (res is not null)
data = res;
return res;
}
var stream = sender.CreateStream(reference, cancellationToken);
var context = new Context(sender, stream)
{
AppId = @event.Token.AppId ?? Id ?? string.Empty,
TenantId = @event.Token.TenantId ?? string.Empty,
Log = Logger.Child(path),
Storage = Storage,
Api = api,
Activity = @event.Activity,
Ref = reference,
IsSignedIn = userToken is not null,
OnNext = Next,
Extra = @event.Extra ?? new Dictionary(),
UserGraphToken = userToken,
CancellationToken = cancellationToken,
ConnectionName = OAuth.DefaultConnectionName,
OnActivitySent = async (activity, context) =>
{
await Events.Emit(
context.Sender,
EventType.ActivitySent,
new ActivitySentEvent() { Activity = activity },
context.CancellationToken
).ConfigureAwait(false);
}
};
stream.OnChunk += async activity =>
{
await Events.Emit(
sender,
EventType.ActivitySent,
new ActivitySentEvent() { Activity = activity },
cancellationToken
).ConfigureAwait(false);
};
if (@event.Services is not null)
{
var accessor = (IContext.Accessor?)@event.Services.GetService(typeof(IContext.Accessor));
if (accessor is not null)
{
accessor.Value = context;
}
}
foreach (var plugin in Plugins)
{
await plugin.OnActivity(this, sender, @event, cancellationToken).ConfigureAwait(false);
}
var res = await Next(context).ConfigureAwait(false);
await stream.Close(cancellationToken).ConfigureAwait(false);
var response = res is Response value
? value
: new Response(System.Net.HttpStatusCode.OK, res);
response.Meta.Routes = i + 1;
response.Meta.Elapse = (DateTime.UtcNow - start).Milliseconds;
await Events.Emit(
sender,
EventType.ActivityResponse,
new ActivityResponseEvent() { Response = response },
cancellationToken
).ConfigureAwait(false);
return response;
}
}