microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
devtools-plan/01-devtools-architecture.md
375lines · modecode
| 1 | # DevTools Architecture on `main` |
| 2 | |
| 3 | How the DevTools plugin works in the current `main` branch of the .NET SDK. |
| 4 | |
| 5 | ## Project |
| 6 | |
| 7 | ``` |
| 8 | Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/ |
| 9 | ``` |
| 10 | |
| 11 | Package: `Microsoft.Teams.Plugins.AspNetCore.DevTools` |
| 12 | Target: `net8.0` |
| 13 | Embedded UI: React/TypeScript app in `web/` folder, served via `ManifestEmbeddedFileProvider` |
| 14 | |
| 15 | ### Dependencies |
| 16 | |
| 17 | - `Microsoft.Teams.Plugins.AspNetCore` (plugin host) |
| 18 | - `Microsoft.Teams.Apps` (app model, plugin interfaces) |
| 19 | - `Microsoft.Teams.Api` (activity types) |
| 20 | - `Microsoft.Teams.Common` (logging, JSON utilities) |
| 21 | - `Microsoft.Teams.Extensions.Hosting` |
| 22 | - `Microsoft.Extensions.FileProviders.Embedded` (9.0.0) |
| 23 | - `System.IdentityModel.Tokens.Jwt` (8.8.0) |
| 24 | |
| 25 | --- |
| 26 | |
| 27 | ## Plugin Class — `DevToolsPlugin.cs` |
| 28 | |
| 29 | Implements `IAspNetCorePlugin`, decorated with `[Plugin]`. |
| 30 | |
| 31 | ```csharp |
| 32 | [Plugin] |
| 33 | public class DevToolsPlugin : IAspNetCorePlugin |
| 34 | { |
| 35 | [Dependency] public ILogger Logger { get; set; } |
| 36 | [Dependency("AppId", optional: true)] public string? AppId { get; set; } |
| 37 | [Dependency("AppName", optional: true)] public string? AppName { get; set; } |
| 38 | |
| 39 | public event EventFunction Events; |
| 40 | |
| 41 | internal MetaData MetaData => new() { Id = AppId, Name = AppName, Pages = _pages }; |
| 42 | internal readonly WebSocketCollection Sockets = []; |
| 43 | |
| 44 | private readonly ISenderPlugin _sender; |
| 45 | private readonly IServiceProvider _services; |
| 46 | private readonly IList<Page> _pages = []; |
| 47 | private readonly TeamsDevToolsSettings _settings; |
| 48 | |
| 49 | public DevToolsPlugin(AspNetCorePlugin sender, IServiceProvider provider) { ... } |
| 50 | ``` |
| 51 | |
| 52 | ### Lifecycle Methods |
| 53 | |
| 54 | | Method | What it does | |
| 55 | |--------|-------------| |
| 56 | | `Configure(IApplicationBuilder)` | Enables WebSockets (`AllowedOrigins = { "*" }`), serves embedded static files at `/devtools` path, adds error-logging middleware | |
| 57 | | `OnInit(App)` | Loads custom pages from `TeamsDevToolsSettings`, logs security warning | |
| 58 | | `OnStart(App)` | Resolves `IServer` addresses, logs `Available at {address}/devtools` for each | |
| 59 | | `OnActivity(App, ISenderPlugin, ActivityEvent)` | Emits `ActivityEvent.Received(activity, conversation)` to all WebSocket clients | |
| 60 | | `OnActivitySent(App, ISenderPlugin, ActivitySentEvent)` | Emits `ActivityEvent.Sent(activity, conversation)` to all WebSocket clients | |
| 61 | | `OnActivityResponse(...)` | No-op (logs debug) | |
| 62 | | `OnError(...)` | No-op (logs debug) | |
| 63 | | `Do(ActivityEvent)` | Delegates to `AspNetCorePlugin` sender — used by `ActivityController` for test injection | |
| 64 | |
| 65 | --- |
| 66 | |
| 67 | ## Controllers |
| 68 | |
| 69 | ### `DevToolsController.cs` — UI + WebSocket |
| 70 | |
| 71 | ```csharp |
| 72 | [ApiController] |
| 73 | public class DevToolsController : ControllerBase |
| 74 | { |
| 75 | private readonly DevToolsPlugin _plugin; |
| 76 | private readonly IFileProvider _files; |
| 77 | private readonly IHostApplicationLifetime _lifetime; |
| 78 | |
| 79 | public DevToolsController(DevToolsPlugin plugin, IHostApplicationLifetime lifetime) { ... } |
| 80 | ``` |
| 81 | |
| 82 | **Endpoints:** |
| 83 | |
| 84 | | Route | Method | Behavior | |
| 85 | |-------|--------|----------| |
| 86 | | `GET /devtools` | `Get(null)` | Serves `index.html` from embedded files | |
| 87 | | `GET /devtools/{*path}` | `Get(path)` | Serves requested file; falls back to `index.html` (SPA routing) | |
| 88 | | `GET /devtools/sockets` | `GetSocket()` | WebSocket upgrade → adds to `Sockets` collection → sends `MetaDataEvent` → loops until close | |
| 89 | |
| 90 | **WebSocket lifecycle:** |
| 91 | 1. Accept WebSocket connection |
| 92 | 2. Assign GUID id, add to `_plugin.Sockets` |
| 93 | 3. Send `MetaDataEvent` with app id, name, and custom pages |
| 94 | 4. Block on `socket.ReceiveAsync()` until socket closes |
| 95 | 5. Remove from `_plugin.Sockets` on disconnect |
| 96 | |
| 97 | ### `ActivityController.cs` — Test Activity Injection |
| 98 | |
| 99 | ```csharp |
| 100 | [ApiController] |
| 101 | [Obsolete("Use Minimal APIs instead.")] |
| 102 | public class ActivityController : ControllerBase |
| 103 | { |
| 104 | private readonly DevToolsPlugin _plugin; |
| 105 | private readonly SecurityKey _securityKey; |
| 106 | ``` |
| 107 | |
| 108 | **Endpoint:** `POST /v3/conversations/{conversationId}/activities` |
| 109 | |
| 110 | **Logic:** |
| 111 | 1. Check for `x-teams-devtools: true` header |
| 112 | 2. If **not** from DevTools client: return `201` with `{ id }` (passthrough for outgoing activities from `ConversationClient`) |
| 113 | 3. If **from** DevTools client: |
| 114 | - Set `from` to `{ id: "devtools", name: "devtools", role: "user" }` |
| 115 | - Set `conversation` to `{ id: conversationId, type: "personal", name: "default" }` |
| 116 | - Set `recipient` to `{ id: appId, name: appName, role: "bot" }` |
| 117 | - Deserialize to `Activity` |
| 118 | - Create fake JWT with `serviceurl` claim pointing at localhost |
| 119 | - Call `_plugin.Do(activityEvent)` — runs through the full sender pipeline |
| 120 | 4. Return `201` with `{ id }` |
| 121 | |
| 122 | --- |
| 123 | |
| 124 | ## Event System |
| 125 | |
| 126 | ### `IEvent` Interface (`Event.cs`) |
| 127 | |
| 128 | ```csharp |
| 129 | [TrueTypeJson<IEvent>] |
| 130 | public interface IEvent |
| 131 | { |
| 132 | public Guid Id { get; } |
| 133 | public string Type { get; } |
| 134 | public object? Body { get; } |
| 135 | public DateTime SentAt { get; } |
| 136 | } |
| 137 | ``` |
| 138 | |
| 139 | The `[TrueTypeJson]` attribute enables polymorphic JSON serialization — the serializer writes the concrete type's properties, not just the interface. |
| 140 | |
| 141 | ### `ActivityEvent.cs` |
| 142 | |
| 143 | ```csharp |
| 144 | public class ActivityEvent : IEvent |
| 145 | { |
| 146 | [JsonPropertyName("id")] public Guid Id { get; } |
| 147 | [JsonPropertyName("type")] public string Type { get; } |
| 148 | [JsonPropertyName("body")] public object? Body { get; } |
| 149 | [JsonPropertyName("chat")] public Conversation Chat { get; set; } |
| 150 | [JsonPropertyName("error")] public object? Error { get; set; } |
| 151 | [JsonPropertyName("sentAt")] public DateTime SentAt { get; } |
| 152 | |
| 153 | public ActivityEvent(string type, IActivity body, Conversation chat) |
| 154 | { |
| 155 | Id = Guid.NewGuid(); |
| 156 | Type = $"activity.{type}"; // → "activity.received", "activity.sent", "activity.error" |
| 157 | Body = body; |
| 158 | Chat = chat; |
| 159 | SentAt = DateTime.Now; |
| 160 | } |
| 161 | |
| 162 | public static ActivityEvent Received(IActivity body, Conversation chat) => new("received", body, chat); |
| 163 | public static ActivityEvent Sent(IActivity body, Conversation chat) => new("sent", body, chat); |
| 164 | public static ActivityEvent Err(IActivity body, Conversation chat, object error) => new("error", body, chat) { Error = error }; |
| 165 | } |
| 166 | ``` |
| 167 | |
| 168 | ### `MetaDataEvent.cs` |
| 169 | |
| 170 | ```csharp |
| 171 | public class MetaDataEvent : IEvent |
| 172 | { |
| 173 | [JsonPropertyName("id")] public Guid Id { get; } |
| 174 | [JsonPropertyName("type")] public string Type { get; } // always "metadata" |
| 175 | [JsonPropertyName("body")] public object? Body { get; } // MetaData object |
| 176 | [JsonPropertyName("sentAt")] public DateTime SentAt { get; } |
| 177 | |
| 178 | public MetaDataEvent(MetaData body) { ... } |
| 179 | } |
| 180 | ``` |
| 181 | |
| 182 | --- |
| 183 | |
| 184 | ## Wire Format (Critical for React UI) |
| 185 | |
| 186 | The embedded React app expects these exact JSON shapes over WebSocket: |
| 187 | |
| 188 | ### Activity events |
| 189 | |
| 190 | ```json |
| 191 | { |
| 192 | "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", |
| 193 | "type": "activity.received", |
| 194 | "body": { /* full Activity object */ }, |
| 195 | "chat": { |
| 196 | "id": "conversation-id", |
| 197 | "type": "personal", |
| 198 | "name": "default" |
| 199 | }, |
| 200 | "sentAt": "2026-03-18T10:30:00" |
| 201 | } |
| 202 | ``` |
| 203 | |
| 204 | `type` values: `"activity.received"`, `"activity.sent"`, `"activity.error"` |
| 205 | |
| 206 | Error events additionally include: |
| 207 | ```json |
| 208 | { |
| 209 | "error": { /* error object */ } |
| 210 | } |
| 211 | ``` |
| 212 | |
| 213 | ### Metadata events |
| 214 | |
| 215 | ```json |
| 216 | { |
| 217 | "id": "guid", |
| 218 | "type": "metadata", |
| 219 | "body": { |
| 220 | "id": "app-id", |
| 221 | "name": "app-name", |
| 222 | "pages": [ |
| 223 | { "icon": "...", "name": "...", "displayName": "...", "url": "..." } |
| 224 | ] |
| 225 | }, |
| 226 | "sentAt": "2026-03-18T10:30:00" |
| 227 | } |
| 228 | ``` |
| 229 | |
| 230 | ### Key detail: `chat` property |
| 231 | |
| 232 | The `chat` property in `ActivityEvent` maps to `Microsoft.Teams.Api.Conversation` on main, which has properties `Id`, `Type` (enum: `Personal`, `Group`, `Channel`), and `Name`. The React UI reads `chat.id`, `chat.type`, and `chat.name`. |
| 233 | |
| 234 | --- |
| 235 | |
| 236 | ## WebSocket Management — `WebSocketCollection.cs` |
| 237 | |
| 238 | ```csharp |
| 239 | public class WebSocketCollection : IEnumerable<KeyValuePair<string, WebSocket>> |
| 240 | { |
| 241 | protected IDictionary<string, WebSocket> _store = new Dictionary<string, WebSocket>(); |
| 242 | |
| 243 | public WebSocket? Get(string key) { ... } |
| 244 | public WebSocketCollection Add(string key, WebSocket value) { ... } |
| 245 | public WebSocketCollection Remove(params string[] keys) { ... } |
| 246 | |
| 247 | // Broadcast to ALL connected clients |
| 248 | public async Task Emit(IEvent @event, CancellationToken ct) |
| 249 | { |
| 250 | var payload = JsonSerializer.SerializeToUtf8Bytes(@event, new JsonSerializerOptions() |
| 251 | { |
| 252 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull |
| 253 | }); |
| 254 | var buffer = new ArraySegment<byte>(payload, 0, payload.Length); |
| 255 | foreach (var socket in _store.Values) |
| 256 | await socket.SendAsync(buffer, WebSocketMessageType.Text, true, ct); |
| 257 | } |
| 258 | |
| 259 | // Send to a SINGLE client by id |
| 260 | public async Task Emit(string key, IEvent @event, CancellationToken ct) { ... } |
| 261 | } |
| 262 | ``` |
| 263 | |
| 264 | --- |
| 265 | |
| 266 | ## Models |
| 267 | |
| 268 | ### `MetaData.cs` |
| 269 | |
| 270 | ```csharp |
| 271 | public class MetaData |
| 272 | { |
| 273 | [JsonPropertyName("id")] public string? Id { get; set; } |
| 274 | [JsonPropertyName("name")] public string? Name { get; set; } |
| 275 | [JsonPropertyName("pages")] public IList<Page> Pages { get; set; } = []; |
| 276 | } |
| 277 | ``` |
| 278 | |
| 279 | ### `Page.cs` |
| 280 | |
| 281 | ```csharp |
| 282 | public class Page |
| 283 | { |
| 284 | [JsonPropertyName("icon")] public string? Icon { get; set; } |
| 285 | [JsonPropertyName("name")] public required string Name { get; set; } |
| 286 | [JsonPropertyName("displayName")] public required string DisplayName { get; set; } |
| 287 | [JsonPropertyName("url")] public required string Url { get; set; } |
| 288 | } |
| 289 | ``` |
| 290 | |
| 291 | --- |
| 292 | |
| 293 | ## Registration — `HostApplicationBuilder.cs` |
| 294 | |
| 295 | ```csharp |
| 296 | public static class HostApplicationBuilderExtensions |
| 297 | { |
| 298 | public static IHostApplicationBuilder AddTeamsDevTools(this IHostApplicationBuilder builder) |
| 299 | { |
| 300 | builder.Services.AddSingleton(builder.Configuration.GetTeamsDevTools()); |
| 301 | builder.Services.AddTeamsPlugin<DevToolsPlugin>(); |
| 302 | builder.Services.AddControllers().AddApplicationPart(Assembly.GetExecutingAssembly()); |
| 303 | return builder; |
| 304 | } |
| 305 | } |
| 306 | ``` |
| 307 | |
| 308 | Configuration binding via `ConfigurationManager.cs`: |
| 309 | ```csharp |
| 310 | public static TeamsDevToolsSettings GetTeamsDevTools(this IConfigurationManager manager) |
| 311 | { |
| 312 | return manager.GetSection("Teams").GetSection("Plugins.DevTools").Get<TeamsDevToolsSettings>() ?? new(); |
| 313 | } |
| 314 | ``` |
| 315 | |
| 316 | Settings POCO: |
| 317 | ```csharp |
| 318 | public class TeamsDevToolsSettings |
| 319 | { |
| 320 | public IList<Page> Pages { get; set; } = []; |
| 321 | } |
| 322 | ``` |
| 323 | |
| 324 | --- |
| 325 | |
| 326 | ## Extension Helpers |
| 327 | |
| 328 | ### `WebSocket.cs` |
| 329 | |
| 330 | ```csharp |
| 331 | public static class WebSocketExtensions |
| 332 | { |
| 333 | public static bool IsCloseable(this WebSocket socket) |
| 334 | { |
| 335 | return socket.State != WebSocketState.Closed && |
| 336 | socket.State != WebSocketState.Aborted; |
| 337 | } |
| 338 | } |
| 339 | ``` |
| 340 | |
| 341 | --- |
| 342 | |
| 343 | ## Usage (main branch) |
| 344 | |
| 345 | ```csharp |
| 346 | var builder = WebApplication.CreateBuilder(args); |
| 347 | builder.AddTeams(); |
| 348 | builder.AddTeamsDevTools(); // registers plugin + settings + MVC controllers |
| 349 | |
| 350 | var app = builder.Build(); |
| 351 | app.MapControllers(); // needed for DevTools MVC controllers |
| 352 | // ... plugin lifecycle handled automatically by Teams app |
| 353 | ``` |
| 354 | |
| 355 | --- |
| 356 | |
| 357 | ## File Inventory |
| 358 | |
| 359 | | File | Type | SDK Dependencies | |
| 360 | |------|------|-----------------| |
| 361 | | `DevToolsPlugin.cs` | Core plugin | `IAspNetCorePlugin`, `ISenderPlugin`, `[Plugin]`, `[Dependency]`, `App` | |
| 362 | | `Controllers/DevToolsController.cs` | MVC controller | `DevToolsPlugin` (injected) | |
| 363 | | `Controllers/ActivityController.cs` | MVC controller | `DevToolsPlugin`, `Activity`, `Conversation`, `Account`, `Role` | |
| 364 | | `Events/ActivityEvent.cs` | Event DTO | `IActivity`, `Conversation` (from `Microsoft.Teams.Api`) | |
| 365 | | `Events/MetaDataEvent.cs` | Event DTO | None (uses `MetaData` model) | |
| 366 | | `Event.cs` (IEvent) | Interface | `[TrueTypeJson]` from `Microsoft.Teams.Common` | |
| 367 | | `WebSocketCollection.cs` | Infrastructure | `IEvent` interface only | |
| 368 | | `Models/Page.cs` | POCO | None | |
| 369 | | `Models/MetaData.cs` | POCO | `Page` | |
| 370 | | `Extensions/HostApplicationBuilder.cs` | DI registration | `AddTeamsPlugin<T>()` from `Microsoft.Teams.Apps.Extensions` | |
| 371 | | `Extensions/ConfigurationManager.cs` | Config binding | None | |
| 372 | | `Extensions/WebSocket.cs` | Helper | None | |
| 373 | | `TeamsDevToolsSettings.cs` | Config POCO | `Page` | |
| 374 | | `Microsoft.Teams.Plugins.AspNetCore.DevTools.csproj` | Project file | References 4 SDK projects | |
| 375 | | `web/` | Embedded React UI | None (framework-agnostic) | |
| 376 | |