// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Teams.Api.Activities; using Microsoft.Teams.Api.Auth; using Microsoft.Teams.Api.Clients; using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Events; using Microsoft.Teams.Apps.Plugins; using Microsoft.Teams.Common.Http; using Microsoft.Teams.Common.Logging; using HttpRequest = Microsoft.AspNetCore.Http.HttpRequest; namespace Microsoft.Teams.Plugins.AspNetCore; [Plugin] public partial class AspNetCorePlugin : ISenderPlugin, IAspNetCorePlugin { [Dependency] public ILogger Logger { get; set; } [Dependency("Token", optional: true)] public IToken? Token { get; set; } [Dependency] public IHttpClient Client { get; set; } public event EventFunction Events; private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public IApplicationBuilder Configure(IApplicationBuilder builder) { return builder; } public Task OnInit(App app, CancellationToken cancellationToken = default) { return Task.CompletedTask; } public Task OnStart(App app, CancellationToken cancellationToken = default) { Logger.Debug("OnStart"); return Task.CompletedTask; } public Task OnError(App app, IPlugin plugin, ErrorEvent @event, CancellationToken cancellationToken = default) { Logger.Debug("OnError"); return Task.CompletedTask; } public Task OnActivity(App app, ISenderPlugin sender, ActivityEvent @event, CancellationToken cancellationToken = default) { Logger.Debug("OnActivity"); return Task.CompletedTask; } public Task OnActivitySent(App app, ISenderPlugin sender, ActivitySentEvent @event, CancellationToken cancellationToken = default) { Logger.Debug("OnActivitySent"); return Task.CompletedTask; } public Task OnActivityResponse(App app, ISenderPlugin sender, ActivityResponseEvent @event, CancellationToken cancellationToken = default) { Logger.Debug("OnActivityResponse"); return Task.CompletedTask; } public Task Send(IActivity activity, Api.ConversationReference reference, CancellationToken cancellationToken = default) { return Send(activity, reference, cancellationToken); } public async Task Send(TActivity activity, Api.ConversationReference reference, CancellationToken cancellationToken = default) where TActivity : IActivity { var client = new ApiClient(reference.ServiceUrl, Client, cancellationToken); activity.Conversation = reference.Conversation; activity.From = reference.Bot; activity.ChannelId = reference.ChannelId; // For targeted messages with an explicit Recipient (proactive sends), preserve it. // Otherwise, use the reference User from the conversation context. #pragma warning disable ExperimentalTeamsTargeted var isTargeted = activity.Recipient?.IsTargeted == true; if (isTargeted && reference.Conversation.Type?.IsPersonal == true) { throw new InvalidOperationException( "Targeted messages are not supported in personal (1:1) chats."); } if (!isTargeted) { activity.Recipient = reference.User; } if (activity.Id is not null && !activity.IsStreaming) { if (isTargeted) { await client .Conversations .Activities .UpdateTargetedAsync(reference.Conversation.Id, activity.Id, activity).ConfigureAwait(false); } else { await client .Conversations .Activities .UpdateAsync(reference.Conversation.Id, activity.Id, activity).ConfigureAwait(false); } return activity; } var res = isTargeted ? await client.Conversations.Activities.CreateTargetedAsync(reference.Conversation.Id, activity).ConfigureAwait(false) : await client.Conversations.Activities.CreateAsync(reference.Conversation.Id, activity).ConfigureAwait(false); #pragma warning restore ExperimentalTeamsTargeted activity.Id = res?.Id; return activity; } public IStreamer CreateStream(Api.ConversationReference reference, CancellationToken cancellationToken = default) { return new Stream() { Send = async activity => { var res = await Send(activity, reference, cancellationToken).ConfigureAwait(false); return res; }, Logger = Logger.Child("stream") }; } public async Task Do(ActivityEvent @event, CancellationToken cancellationToken = default) { try { var @out = await Events( this, "activity", @event, cancellationToken ).ConfigureAwait(false); var res = (Response?)@out ?? throw new Exception("expected activity response"); Logger.Debug(res); return res; } catch (Exception ex) { Logger.Error(ex); await Events( this, "error", new ErrorEvent() { Exception = ex }, cancellationToken ).ConfigureAwait(false); return new Response(System.Net.HttpStatusCode.InternalServerError, ex.ToString()); } } public async Task Do(HttpContext httpContext, CancellationToken cancellationToken = default) { try { var request = httpContext.Request; var token = ExtractToken(request); var activity = await ParseActivity(request).ConfigureAwait(false); if (activity is null) { return Results.BadRequest("Missing activity"); } // Require the token's serviceurl claim to match the activity's serviceUrl // (normalized, case-insensitive) when the activity specifies one. Mismatches // are logged server-side. if (!string.IsNullOrEmpty(activity.ServiceUrl)) { var claimServiceUrl = token.Token.Payload.TryGetValue("serviceurl", out var serviceUrlClaim) ? serviceUrlClaim as string : null; if (!NormalizeServiceUrl(claimServiceUrl).Equals(NormalizeServiceUrl(activity.ServiceUrl), StringComparison.OrdinalIgnoreCase)) { Logger.Warn($"Rejecting activity {activity.Id}: serviceUrl '{activity.ServiceUrl}' does not match the token serviceurl claim '{claimServiceUrl}'."); return Results.Unauthorized(); } } var data = new Dictionary { ["Request.TraceId"] = httpContext.TraceIdentifier }; foreach (var pair in httpContext.Items) { var key = pair.Key.ToString(); if (key is null) continue; data[key] = pair.Value; } var res = await Do(new ActivityEvent() { Token = token, Activity = activity, Extra = data, Services = httpContext.RequestServices }, cancellationToken).ConfigureAwait(false); // convert response metadata to headers foreach (var (key, value) in res.Meta) { var str = value?.ToString(); if (string.IsNullOrEmpty(str)) continue; httpContext.Response.Headers.Append($"X-Teams-{char.ToUpper(key[0]) + key[1..]}", str); } return Results.Json( res.Body, _jsonSerializerOptions, contentType: null, statusCode: (int)res.Status ); } catch (Exception ex) { Logger.Error(ex); await Events( this, "error", new ErrorEvent() { Exception = ex }, cancellationToken ).ConfigureAwait(false); return Results.Problem(detail: ex.Message, statusCode: 500); } } // Normalize a serviceUrl for comparison: null/empty becomes empty, and a single // trailing slash is trimmed so "https://host/teams" and "https://host/teams/" match. private static string NormalizeServiceUrl(string? serviceUrl) => string.IsNullOrEmpty(serviceUrl) ? string.Empty : serviceUrl.EndsWith('/') ? serviceUrl[..^1] : serviceUrl; public JsonWebToken ExtractToken(HttpRequest httpRequest) { var authHeader = httpRequest.Headers.Authorization.FirstOrDefault() ?? throw new UnauthorizedAccessException(); return new JsonWebToken(authHeader.Replace("Bearer ", "")); } public async Task ParseActivity(HttpRequest httpRequest) { httpRequest.EnableBuffering(); if (httpRequest.Body.CanSeek) { // reset the stream position to the beginning in case it was read before httpRequest.Body.Position = 0; } using StreamReader sr = new(httpRequest.Body); var body = await sr.ReadToEndAsync().ConfigureAwait(false); Activity? activity = JsonSerializer.Deserialize(body); return activity; } }