diff --git a/.gitignore b/.gitignore index def52263..7c590995 100644 --- a/.gitignore +++ b/.gitignore @@ -491,3 +491,7 @@ $RECYCLE.BIN/ # Claude local settings .claude/settings.local.json + +# Claude session and private files +session-context.md +*-private.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..06a84e8f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,178 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Startup + +Before responding to the user's first message, complete these steps: + +### 1. Read knowledge files +- Read `Claude-KB.md` in this directory (domain knowledge, lessons learned). Create it if it doesn't exist with a `## Lessons Learned` heading. +- **Don't read md files from the parent directory unless the user requests it** — this slows down session start. +- Look for a `*-private.md` file matching the user's name (e.g., `Rajan-private.md`). If one exists, read it — it contains personal TODOs, preferences, and reminders. These files are gitignored and never committed. +- The private file may reference a durable location (e.g., a private git repo). If it does, also read and update that location for persistent notes and TODOs. + +### 2. Read session context +- Read `session-context.md` if it exists. It contains ephemeral state from the previous session: what was in flight, what to pick up, any "don't forget" items. This file is gitignored and overwritten each save. +- Surface relevant items in the greeting (e.g., "Last session you were working on PR 1234"). + +### 3. Greet the user and surface +- Any open TODOs or reminders from private notes +- Common scenarios / quick-start commands: + - **Build the solution** — `dotnet build` + - **Run all tests** — `dotnet test` + - **Run a specific test** — `dotnet test --filter "FullyQualifiedName~TestName"` + - **Run a sample app** — `dotnet run --project Samples/Samples.Echo` + - **Format code** — `dotnet format` + - **Create NuGet packages** — `dotnet pack` + +## Build Commands + +```bash +dotnet build # Build solution +dotnet test # Run all tests +dotnet test -v d # Run tests with detailed verbosity +dotnet format # Format code (EditorConfig enforced) +dotnet pack # Create NuGet packages +``` + +Run a specific test project: +```bash +dotnet test Tests/Microsoft.Teams.Apps.Tests +``` + +Run a single test by name: +```bash +dotnet test --filter "FullyQualifiedName~TestMethodName" +``` + +Run tests with coverage: +```bash +dotnet test --collect:"XPlat Code Coverage" +``` + +Run a specific sample: +```bash +dotnet run --project Samples/Samples.Echo +dotnet run --project Samples/Samples.Lights +``` + +## Development Workflow + +- Cannot push directly to main - all changes require a pull request +- Create a feature branch, make changes, then open a PR +- CI runs build, test, and lint checks on PRs + +## Architecture Overview + +This is the Microsoft Teams SDK for .NET (`Microsoft.Teams.sln`) - a suite of packages for building Teams bots and apps. + +### Core Libraries (Libraries/) + +- **Microsoft.Teams.Apps** - Core bot functionality: activity handling, message processing, routing, context management +- **Microsoft.Teams.AI** - AI/LLM integration: chat plugins, function definitions, prompt templates +- **Microsoft.Teams.AI.Models.OpenAI** - OpenAI-specific model implementation +- **Microsoft.Teams.Api** - Teams API client for bot-to-Teams communication +- **Microsoft.Teams.Cards** - Adaptive Cards support +- **Microsoft.Teams.Common** - Shared utilities, JSON helpers, HTTP, logging, storage patterns + +### Extensions (Libraries/Microsoft.Teams.Extensions/) + +- **Configuration** - Configuration helpers +- **Hosting** - ASP.NET Core DI integration +- **Logging** - Microsoft.Extensions.Logging integration +- **Graph** - Microsoft Graph integration + +### Plugins (Libraries/Microsoft.Teams.Plugins/) + +- **AspNetCore** - Core middleware for ASP.NET Core +- **AspNetCore.DevTools** - Development tools +- **AspNetCore.BotBuilder** - Bot Builder SDK adapter +- **External.Mcp** / **External.McpClient** - Model Context Protocol integration + +## Code Patterns + +### Basic App Setup + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.AddTeams(); // Register Teams services +var app = builder.Build(); +var teams = app.UseTeams(); // Get Teams middleware + +teams.OnMessage(async context => { // Handle messages + await context.Send("Hello!"); +}); + +app.Run(); +``` + +### AI Plugin + +```csharp +[Prompt] +[Prompt.Description("description")] +[Prompt.Instructions("system instructions")] +public class MyPrompt(IContext.Accessor accessor) +{ + [Function] + [Function.Description("what this function does")] + public string MyFunction([Param("param description")] string input) + { + return "result"; + } +} +``` + +## Code Style + +EditorConfig is strictly enforced. Key conventions: + +- **Namespaces**: File-scoped (`namespace Foo;`) +- **Fields**: `_camelCase` for private, `s_camelCase` for private static +- **Nullable**: Enabled throughout +- **Async**: All async methods, CancellationToken support + +All files require Microsoft copyright header: +```csharp +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +``` + +## Testing + +- xUnit with Moq for mocking, implicit `using Xunit` in test projects +- Test projects target net9.0 (libraries target net8.0) +- Test naming: `{LibraryName}.Tests` in `Tests/` directory +- Use `Microsoft.Teams.Apps.Testing` for test utilities + +## Lessons Learned + +This workspace is a **learning system**. Claude-KB.md contains a `## Lessons Learned` section that persists knowledge across sessions. + +### When to add an entry + +Proactively add a lesson whenever you encounter: + +- **Unexpected behavior** — an API, tool, or workflow didn't work as expected and you found the cause +- **Workarounds** — a problem required a non-obvious solution that future sessions should know about +- **User preferences** — the user corrects your approach or states a preference +- **Process discoveries** — you learn how something actually works vs. how it's documented +- **Pitfalls** — something that wasted time and could be avoided next time + +### How to add an entry + +Append to the `## Lessons Learned` section in `Claude-KB.md` using this format: + +```markdown +### YYYY-MM-DD: Short descriptive title +Description of what happened and what to do differently. Keep it concise and actionable. +``` + +### Guidelines + +- Write for your future self — assume no prior context from this session +- Be specific: include tool names, flag names, error messages, or exact steps +- Don't duplicate existing entries — read the section first +- One entry per distinct lesson; don't bundle unrelated things +- Ask the user before adding if you're unsure whether something qualifies diff --git a/Claude-KB.md b/Claude-KB.md new file mode 100644 index 00000000..1db80c16 --- /dev/null +++ b/Claude-KB.md @@ -0,0 +1,3 @@ +# Claude Knowledge Base — Microsoft Teams SDK for .NET + +## Lessons Learned diff --git a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs index 7ae6d974..07d90593 100644 --- a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs +++ b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs @@ -11,6 +11,12 @@ public class ClientCredentials : IHttpCredentials public string ClientSecret { get; set; } public string? TenantId { get; set; } + /// + /// The Entra ID login endpoint, following the Microsoft Identity Web configuration schema. + /// Override this for sovereign clouds (e.g. "https://login.microsoftonline.us" for US Gov). + /// + public string Instance { get; set; } = "https://login.microsoftonline.com"; + public ClientCredentials(string clientId, string clientSecret) { ClientId = clientId; @@ -27,8 +33,9 @@ public ClientCredentials(string clientId, string clientSecret, string? tenantId) public async Task Resolve(IHttpClient client, string[] scopes, CancellationToken cancellationToken = default) { var tenantId = TenantId ?? "botframework.com"; + var instance = Instance.TrimEnd('/'); var request = HttpRequest.Post( - $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token" + $"{instance}/{tenantId}/oauth2/v2.0/token" ); request.Headers.Add("Content-Type", ["application/x-www-form-urlencoded"]); diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index 11a4e532..f0b8bffc 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -11,6 +11,12 @@ public class TeamsSettings public string? ClientSecret { get; set; } public string? TenantId { get; set; } + /// + /// The Entra ID login endpoint, following the Microsoft Identity Web configuration schema. + /// Override this for sovereign clouds (e.g. "https://login.microsoftonline.us" for US Gov). + /// + public string? Instance { get; set; } + public bool Empty { get { return ClientId == "" || ClientSecret == ""; } @@ -22,7 +28,14 @@ public AppOptions Apply(AppOptions? options = null) if (ClientId is not null && ClientSecret is not null && !Empty) { - options.Credentials = new ClientCredentials(ClientId, ClientSecret, TenantId); + var credentials = new ClientCredentials(ClientId, ClientSecret, TenantId); + + if (Instance is not null) + { + credentials.Instance = Instance; + } + + options.Credentials = credentials; } return options; diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs index 01f28920..666eec4a 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs @@ -34,11 +34,18 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder // client credentials if (options.Credentials is null && settings.ClientId is not null && settings.ClientSecret is not null && !settings.Empty) { - options.Credentials = new ClientCredentials( + var credentials = new ClientCredentials( settings.ClientId, settings.ClientSecret, settings.TenantId ); + + if (settings.Instance is not null) + { + credentials.Instance = settings.Instance; + } + + options.Credentials = credentials; } options.Logger ??= new ConsoleLogger(loggingSettings); @@ -59,11 +66,18 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder // client credentials if (settings.ClientId is not null && settings.ClientSecret is not null && !settings.Empty) { - appBuilder = appBuilder.AddCredentials(new ClientCredentials( + var credentials = new ClientCredentials( settings.ClientId, settings.ClientSecret, settings.TenantId - )); + ); + + if (settings.Instance is not null) + { + credentials.Instance = settings.Instance; + } + + appBuilder = appBuilder.AddCredentials(credentials); } var app = appBuilder.Build(); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs index 760d94da..81862751 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs @@ -121,6 +121,12 @@ public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostAppl var settings = builder.Configuration.GetTeams(); var teamsValidationSettings = new TeamsValidationSettings(); + + if (!string.IsNullOrEmpty(settings.Instance)) + { + teamsValidationSettings.Instance = settings.Instance; + } + if (!string.IsNullOrEmpty(settings.ClientId)) { teamsValidationSettings.AddDefaultAudiences(settings.ClientId); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs index 5443c928..551ef9ac 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs @@ -6,13 +6,14 @@ public class TeamsValidationSettings public List Audiences = []; public List Issuers = [ "https://api.botframework.com", - "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", // Emulator Auth v3.1, 1.0 token - "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", // Emulator Auth v3.1, 2.0 token - "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", // Emulator Auth v3.2, 1.0 token - "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", // Emulator Auth v3.2, 2.0 token - "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", // Copilot Auth v1.0 token - "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", // Copilot Auth v2.0 token - ]; + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", // Emulator Auth v3.1, 1.0 token + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", // Emulator Auth v3.1, 2.0 token + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", // Emulator Auth v3.2, 1.0 token + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", // Emulator Auth v3.2, 2.0 token + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", // Copilot Auth v1.0 token + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", // Copilot Auth v2.0 token + ]; + public string Instance { get; set; } = "https://login.microsoftonline.com"; public void AddDefaultAudiences(string ClientId) { @@ -29,13 +30,15 @@ public IEnumerable GetValidIssuersForTenant(string? tenantId) var validIssuers = new List(); if (!string.IsNullOrEmpty(tenantId)) { - validIssuers.Add($"https://login.microsoftonline.com/{tenantId}/"); + var instance = Instance.TrimEnd('/'); + validIssuers.Add($"{instance}/{tenantId}/"); } return validIssuers; } public string GetTenantSpecificOpenIdMetadataUrl(string? tenantId) { - return $"https://login.microsoftonline.com/{tenantId ?? "common"}/v2.0/.well-known/openid-configuration"; + var instance = Instance.TrimEnd('/'); + return $"{instance}/{tenantId ?? "common"}/v2.0/.well-known/openid-configuration"; } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/Auth/ClientCredentialsTests.cs b/Tests/Microsoft.Teams.Api.Tests/Auth/ClientCredentialsTests.cs new file mode 100644 index 00000000..aaa4d618 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Auth/ClientCredentialsTests.cs @@ -0,0 +1,35 @@ +using Microsoft.Teams.Api.Auth; + +namespace Microsoft.Teams.Api.Tests.Auth; + +public class ClientCredentialsTests +{ + [Fact] + public void Instance_DefaultsToPublicCloud() + { + var credentials = new ClientCredentials("client-id", "client-secret"); + + Assert.Equal("https://login.microsoftonline.com", credentials.Instance); + } + + [Fact] + public void Instance_CanBeOverridden() + { + var credentials = new ClientCredentials("client-id", "client-secret") + { + Instance = "https://login.microsoftonline.us" + }; + + Assert.Equal("https://login.microsoftonline.us", credentials.Instance); + } + + [Fact] + public void Constructor_WithTenantId_SetsProperties() + { + var credentials = new ClientCredentials("client-id", "client-secret", "tenant-id"); + + Assert.Equal("client-id", credentials.ClientId); + Assert.Equal("client-secret", credentials.ClientSecret); + Assert.Equal("tenant-id", credentials.TenantId); + } +} diff --git a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs new file mode 100644 index 00000000..265d9840 --- /dev/null +++ b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Teams.Plugins.AspNetCore.Extensions; + +namespace Microsoft.Teams.Plugins.AspNetCore.Tests.Extensions; + +public class TeamsValidationSettingsTests +{ + [Fact] + public void Instance_DefaultsToPublicCloud() + { + var settings = new TeamsValidationSettings(); + + Assert.Equal("https://login.microsoftonline.com", settings.Instance); + } + + [Fact] + public void GetValidIssuersForTenant_UsesDefaultInstance() + { + var settings = new TeamsValidationSettings(); + var issuers = settings.GetValidIssuersForTenant("test-tenant").ToList(); + + Assert.Single(issuers); + Assert.Equal("https://login.microsoftonline.com/test-tenant/", issuers[0]); + } + + [Fact] + public void GetValidIssuersForTenant_UsesCustomInstance() + { + var settings = new TeamsValidationSettings + { + Instance = "https://login.microsoftonline.us" + }; + var issuers = settings.GetValidIssuersForTenant("test-tenant").ToList(); + + Assert.Single(issuers); + Assert.Equal("https://login.microsoftonline.us/test-tenant/", issuers[0]); + } + + [Fact] + public void GetValidIssuersForTenant_HandlesTrailingSlashInInstance() + { + var settings = new TeamsValidationSettings + { + Instance = "https://login.microsoftonline.us/" + }; + var issuers = settings.GetValidIssuersForTenant("test-tenant").ToList(); + + Assert.Single(issuers); + Assert.Equal("https://login.microsoftonline.us/test-tenant/", issuers[0]); + } + + [Fact] + public void GetTenantSpecificOpenIdMetadataUrl_UsesDefaultInstance() + { + var settings = new TeamsValidationSettings(); + var url = settings.GetTenantSpecificOpenIdMetadataUrl("test-tenant"); + + Assert.Equal("https://login.microsoftonline.com/test-tenant/v2.0/.well-known/openid-configuration", url); + } + + [Fact] + public void GetTenantSpecificOpenIdMetadataUrl_UsesCustomInstance() + { + var settings = new TeamsValidationSettings + { + Instance = "https://login.microsoftonline.us" + }; + var url = settings.GetTenantSpecificOpenIdMetadataUrl("test-tenant"); + + Assert.Equal("https://login.microsoftonline.us/test-tenant/v2.0/.well-known/openid-configuration", url); + } + + [Fact] + public void GetTenantSpecificOpenIdMetadataUrl_UsesCommon_WhenTenantIdIsNull() + { + var settings = new TeamsValidationSettings(); + var url = settings.GetTenantSpecificOpenIdMetadataUrl(null); + + Assert.Equal("https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration", url); + } + + [Fact] + public void GetValidIssuersForTenant_ReturnsEmpty_WhenTenantIdIsNull() + { + var settings = new TeamsValidationSettings(); + var issuers = settings.GetValidIssuersForTenant(null).ToList(); + + Assert.Empty(issuers); + } +}