Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/core.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@
<Project Path="src/Microsoft.Teams.Bot.Apps/Microsoft.Teams.Bot.Apps.csproj" Id="0e993a63-8a1a-4cdf-8a29-cc8c59bd6c30" />
<Project Path="src/Microsoft.Teams.Bot.Compat/Microsoft.Teams.Bot.Compat.csproj" Id="42d14898-dcc0-43a4-bb61-60e289f63c44" />
<Project Path="src/Microsoft.Teams.Bot.Core/Microsoft.Teams.Bot.Core.csproj" />
<Project Path="src/Microsoft.Teams.Bot.DevTools/Microsoft.Teams.Bot.DevTools.csproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Logging;
using Microsoft.Teams.Bot.Core;
using Microsoft.Teams.Bot.Core.Schema;

namespace Microsoft.Teams.Bot.DevTools;

using CustomHeaders = Dictionary<string, string>;

/// <summary>
/// Decorator around <see cref="ConversationClient"/> that emits "sent" events to DevTools UI clients
/// whenever an activity is sent.
/// </summary>
public class DevToolsConversationClient : ConversationClient
{
private readonly DevToolsService _service;

/// <summary>
/// Creates a new DevToolsConversationClient.
/// </summary>
/// <param name="httpClient">The HTTP client for sending activities.</param>
/// <param name="logger">The logger.</param>
/// <param name="service">The shared DevTools service for emitting events.</param>
public DevToolsConversationClient(HttpClient httpClient, ILogger<ConversationClient> logger, DevToolsService service)
: base(httpClient, logger)
{
_service = service;
}

/// <inheritdoc/>
public override async Task<SendActivityResponse> SendActivityAsync(CoreActivity activity, CustomHeaders? customHeaders = null, CancellationToken cancellationToken = default)
{
var response = await base.SendActivityAsync(activity, customHeaders, cancellationToken).ConfigureAwait(false);

// Emit sent event after successful send
await _service.EmitSent(activity, cancellationToken).ConfigureAwait(false);

return response;
}
}
283 changes: 283 additions & 0 deletions core/src/Microsoft.Teams.Bot.DevTools/DevToolsHostingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net.WebSockets;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Teams.Bot.Core;
using Microsoft.Teams.Bot.Core.Hosting;
using Microsoft.Teams.Bot.Core.Schema;
using Microsoft.Teams.Bot.DevTools.Events;
using Microsoft.Teams.Bot.DevTools.Extensions;

namespace Microsoft.Teams.Bot.DevTools;

/// <summary>
/// Extension methods for registering DevTools services and endpoints.
/// </summary>
public static partial class DevToolsHostingExtensions
{
[LoggerMessage(Level = LogLevel.Warning, Message = "DevTools are not secure and should not be used in production environments")]
private static partial void LogDevToolsSecurityWarning(ILogger logger);

[LoggerMessage(Level = LogLevel.Information, Message = "DevTools available at {Address}/devtools")]
private static partial void LogDevToolsAvailable(ILogger logger, string address);
/// <summary>
/// The named HttpClient name used by ConversationClient, duplicated here because the constant is internal.
/// </summary>
private const string ConversationHttpClientName = "BotConversationClient";

/// <summary>
/// Registers DevTools services: settings, shared service, middleware, and replaces
/// ConversationClient with the DevTools decorator that emits "sent" events.
/// Call this after <c>AddTeamsBotApplication()</c> or <c>AddBotApplication()</c>.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDevTools(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

// Register settings from configuration
services.AddSingleton<DevToolsSettings>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return config.GetSection("DevTools").Get<DevToolsSettings>() ?? new();
});

// Register shared service
services.AddSingleton<DevToolsService>();

// Register middleware
services.AddSingleton<DevToolsMiddleware>();

// Replace ConversationClient with DevToolsConversationClient.
// AddBotApplication registers ConversationClient via AddHttpClient<ConversationClient>(...).
// We remove that registration and re-register with our decorator.
var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ConversationClient));
if (descriptor != null)
{
services.Remove(descriptor);
}

services.AddHttpClient<ConversationClient, DevToolsConversationClient>(ConversationHttpClientName);

return services;
}

/// <summary>
/// Configures the DevTools middleware and endpoints. Enables WebSockets, serves the embedded React UI,
/// maps WebSocket and test activity injection endpoints, and registers the DevTools middleware on the bot.
/// Call this after <c>UseBotApplication()</c> or <c>UseTeamsBotApplication()</c>.
/// </summary>
/// <param name="endpoints">The endpoint route builder (typically <c>WebApplication</c>).</param>
/// <returns>The endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder UseDevTools(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);

// Enable WebSockets
if (endpoints is IApplicationBuilder app)
{
app.UseWebSockets(new WebSocketOptions()
{
AllowedOrigins = { "*" }
});

// Serve embedded static files at /devtools
app.UseStaticFiles(new StaticFileOptions()
{
FileProvider = new ManifestEmbeddedFileProvider(Assembly.GetExecutingAssembly(), "web"),
ServeUnknownFileTypes = true,
RequestPath = "/devtools"
});
}

// Resolve services
var service = endpoints.ServiceProvider.GetRequiredService<DevToolsService>();
var middleware = endpoints.ServiceProvider.GetRequiredService<DevToolsMiddleware>();
var lifetime = endpoints.ServiceProvider.GetRequiredService<IHostApplicationLifetime>();
var files = new ManifestEmbeddedFileProvider(Assembly.GetExecutingAssembly(), "web");
var logger = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("DevTools");

// Register middleware on the bot application
var botApp = endpoints.ServiceProvider.GetRequiredService<BotApplication>();
botApp.UseMiddleware(middleware);

// Populate AppId from BotApplicationOptions
var options = endpoints.ServiceProvider.GetService<BotApplicationOptions>();
service.AppId = options?.AppId;

// Log DevTools URLs on application start
lifetime.ApplicationStarted.Register(() =>
{
var server = endpoints.ServiceProvider.GetRequiredService<IServer>();
var addresses = server.Features.GetRequiredFeature<IServerAddressesFeature>().Addresses;
LogDevToolsSecurityWarning(logger);
foreach (var address in addresses)
{
LogDevToolsAvailable(logger, address);
}
});

// Map endpoints
MapDevToolsEndpoints(endpoints, service, lifetime, files, botApp);

return endpoints;
}

/// <summary>
/// Configures DevTools and returns the bot application for chaining.
/// </summary>
/// <typeparam name="TApp">The bot application type.</typeparam>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The bot application instance.</returns>
public static TApp UseDevTools<TApp>(this IEndpointRouteBuilder endpoints) where TApp : BotApplication
{
ArgumentNullException.ThrowIfNull(endpoints);

endpoints.UseDevTools();
return endpoints.ServiceProvider.GetRequiredService<TApp>();
}

private static void MapDevToolsEndpoints(
IEndpointRouteBuilder endpoints,
DevToolsService service,
IHostApplicationLifetime lifetime,
ManifestEmbeddedFileProvider files,
BotApplication botApp)
{
// Serve React UI — SPA fallback to index.html
endpoints.MapGet("/devtools/{*path}", (string? path) =>
{
var file = files.GetFileInfo(path ?? "index.html");
if (!file.Exists)
{
file = files.GetFileInfo("index.html");
}

return Results.File(file.CreateReadStream(), contentType: "text/html");
});

endpoints.MapGet("/devtools", () =>
{
var file = files.GetFileInfo("index.html");
return Results.File(file.CreateReadStream(), contentType: "text/html");
});

// WebSocket endpoint
endpoints.MapGet("/devtools/sockets", async (HttpContext context) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}

using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
var id = Guid.NewGuid().ToString();
var buffer = new byte[1024];

service.Sockets.Add(id, socket);
await service.Sockets.Emit(id, new MetaDataEvent(service.MetaData), lifetime.ApplicationStopping).ConfigureAwait(false);

try
{
while (socket.State.HasFlag(WebSocketState.Open))
{
await socket.ReceiveAsync(buffer, lifetime.ApplicationStopping).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Server shutting down — expected
}
catch (WebSocketException)
{
// Connection closed unexpectedly — expected
}
finally
{
if (socket.IsCloseable())
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, lifetime.ApplicationStopping).ConfigureAwait(false);
}
}

service.Sockets.Remove(id);
});

// Test activity injection endpoint (replaces ActivityController from main)
endpoints.MapPost("/v3/conversations/{conversationId}/activities", async (
string conversationId,
HttpContext context,
CancellationToken cancellationToken) =>
{
// Read body as JsonNode
var body = await JsonNode.ParseAsync(context.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false);
if (body is null)
{
return Results.BadRequest();
}

var isDevTools = context.Request.Headers.TryGetValue("x-teams-devtools", out var headerValues)
&& headerValues.Any(h => h == "true");

body["id"] ??= Guid.NewGuid().ToString();

// If not from DevTools client, return 201 (passthrough for outgoing activity responses)
if (!isDevTools)
{
return Results.Json(new { id = body["id"]?.ToString() }, statusCode: 201);
}

// Set default from/conversation/recipient for DevTools test messages
body["from"] ??= JsonSerializer.SerializeToNode(new ConversationAccount
{
Id = "devtools",
Name = "devtools"
});

body["conversation"] = JsonSerializer.SerializeToNode(new
{
id = conversationId,
type = "personal",
name = "default"
});

body["recipient"] = JsonSerializer.SerializeToNode(new ConversationAccount
{
Id = service.AppId ?? string.Empty,
Name = service.AppName
});

// Create a test HttpContext and route through ProcessAsync
var activityJson = body.ToJsonString();
var stream = new MemoryStream(Encoding.UTF8.GetBytes(activityJson));

var testContext = new DefaultHttpContext
{
RequestServices = context.RequestServices
};
testContext.Request.Body = stream;
testContext.Request.ContentType = "application/json";

await botApp.ProcessAsync(testContext, cancellationToken).ConfigureAwait(false);

return Results.Json(new { id = body["id"]?.ToString() }, statusCode: 201);
});
}
}
44 changes: 44 additions & 0 deletions core/src/Microsoft.Teams.Bot.DevTools/DevToolsMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Teams.Bot.Core;
using Microsoft.Teams.Bot.Core.Schema;

namespace Microsoft.Teams.Bot.DevTools;

/// <summary>
/// Middleware that intercepts incoming activities and errors, emitting events to DevTools UI clients.
/// </summary>
public class DevToolsMiddleware : ITurnMiddleware
{
private readonly DevToolsService _service;

/// <summary>
/// Creates a new DevToolsMiddleware.
/// </summary>
/// <param name="service">The shared DevTools service.</param>
public DevToolsMiddleware(DevToolsService service)
{
_service = service;
}

/// <inheritdoc/>
public async Task OnTurnAsync(BotApplication botApplication, CoreActivity activity, NextTurn nextTurn, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(nextTurn);

// Emit received event before processing
await _service.EmitReceived(activity, cancellationToken).ConfigureAwait(false);

try
{
await nextTurn(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Emit error event, then re-throw so BotApplication error handling still works
await _service.EmitError(activity, new { message = ex.Message, stackTrace = ex.StackTrace }, cancellationToken).ConfigureAwait(false);
throw;
}
}
}
Loading
Loading