From 77c3004b437cc1b91d68f9738ded5d7c75399378 Mon Sep 17 00:00:00 2001 From: Bo Yin <103488@lessen.com> Date: Mon, 29 Jun 2026 16:18:35 -0500 Subject: [PATCH 1/2] Migrate MicrosoftTeams plugin from Bot Framework SDK to M365 Agents SDK v1.6.150 Replace Microsoft.Bot.Builder.Integration.AspNet.Core (v4.22.7) with the Microsoft 365 Agents SDK (Microsoft.Agents.* v1.6.150). Package changes: - Add Microsoft.Agents.Hosting.AspNetCore, Microsoft.Agents.Builder, Microsoft.Agents.Authentication.Msal - Remove Microsoft.Bot.Builder.Integration.AspNet.Core Key changes: - All Microsoft.Bot.* namespaces replaced with Microsoft.Agents.* equivalents - Auth registration: ConfigurationBotFrameworkAuthentication replaced with AddDefaultMsalAuth + AddAgent - CloudAdapter constructor updated to new signature (IChannelServiceClientFactory, IActivityTaskQueue, ...) - ActivityHandler moved to Microsoft.Agents.Builder.Compat namespace - Activity types and models moved to Microsoft.Agents.Core.Models - Proactive messaging: ContinueConversationAsync now takes ClaimsIdentity via AgentClaims.CreateIdentity instead of raw appId string - Inbound endpoint registered via MapAgentApplicationEndpoints() in IBotSharpAppPlugin.Configure (route: /teams/api/messages) instead of a controller action, avoiding MVC application-part discovery issues - Scoped BotSharp services resolved via CreateScope() inside turn handlers since IAgent is resolved from the root DI provider by the SDK - TeamsRequestState removed; agentId now read from MicrosoftTeamsSetting - MicrosoftTeamsSetting.AllowUnauthenticated added for local debugging with Agents Playground (set true in appsettings.Development.json) Co-Authored-By: Claude Sonnet 4.6 --- Directory.Packages.props | 4 +- .../BotSharp.Plugin.MicrosoftTeams.csproj | 4 +- .../Controllers/TeamsMessageController.cs | 30 ++-------- .../MicrosoftTeamsPlugin.cs | 58 +++++++++++++------ .../Services/AdaptiveCardConverter.cs | 8 +-- .../Services/IConversationReferenceStore.cs | 2 +- .../InMemoryConversationReferenceStore.cs | 2 +- .../Services/TeamsActivityBot.cs | 31 +++++----- .../Services/TeamsAdapter.cs | 15 +++-- .../Services/TeamsMessageHandler.cs | 2 +- .../Services/TeamsNotificationService.cs | 14 +++-- .../Settings/MicrosoftTeamsSetting.cs | 7 +++ 12 files changed, 101 insertions(+), 76 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 37515cceb..1e83692ce 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -101,7 +101,9 @@ - + + + diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/BotSharp.Plugin.MicrosoftTeams.csproj b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/BotSharp.Plugin.MicrosoftTeams.csproj index 44bc3a0c7..aebecf3e9 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/BotSharp.Plugin.MicrosoftTeams.csproj +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/BotSharp.Plugin.MicrosoftTeams.csproj @@ -15,7 +15,9 @@ - + + + diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Controllers/TeamsMessageController.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Controllers/TeamsMessageController.cs index 72db3bca7..b1781359d 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Controllers/TeamsMessageController.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Controllers/TeamsMessageController.cs @@ -1,47 +1,27 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Bot.Builder; -using Microsoft.Bot.Builder.Integration.AspNet.Core; namespace BotSharp.Plugin.MicrosoftTeams.Controllers; +/// +/// Inbound Teams activities are handled by MapAgentApplicationEndpoints() registered in +/// MicrosoftTeamsPlugin.Configure — no controller action needed for that path. +/// This controller only exposes the outbound proactive-push API. +/// [ApiController] public class TeamsMessageController : ControllerBase { - private readonly IBotFrameworkHttpAdapter _adapter; - private readonly IBot _bot; - private readonly TeamsRequestState _requestState; private readonly ITeamsNotificationService _notification; private readonly MicrosoftTeamsSetting _setting; public TeamsMessageController( - IBotFrameworkHttpAdapter adapter, - IBot bot, - TeamsRequestState requestState, ITeamsNotificationService notification, MicrosoftTeamsSetting setting) { - _adapter = adapter; - _bot = bot; - _requestState = requestState; _notification = notification; _setting = setting; } - /// - /// Inbound endpoint registered as the Azure Bot "messaging endpoint". - /// Authentication is performed by the Bot Framework JWT pipeline inside the adapter, - /// so the action itself is anonymous. - /// https://learn.microsoft.com/azure/bot-service/bot-builder-basics - /// - [AllowAnonymous] - [HttpPost("/teams/messages/{agentId}")] - public async Task PostAsync([FromRoute] string agentId) - { - _requestState.AgentId = agentId; - await _adapter.ProcessAsync(Request, Response, _bot); - } - /// /// Outbound (proactive) push. Requires the platform's standard authorization. /// diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/MicrosoftTeamsPlugin.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/MicrosoftTeamsPlugin.cs index ecfc2f2e5..b94156edb 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/MicrosoftTeamsPlugin.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/MicrosoftTeamsPlugin.cs @@ -1,16 +1,18 @@ -using Microsoft.Bot.Builder; -using Microsoft.Bot.Builder.Integration.AspNet.Core; -using Microsoft.Bot.Connector.Authentication; +using Microsoft.Agents.Authentication.Msal; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; namespace BotSharp.Plugin.MicrosoftTeams; /// -/// Two-way Microsoft Teams integration built on Azure Bot Service / Bot Framework. +/// Two-way Microsoft Teams integration built on Azure Bot Service / Microsoft 365 Agents SDK. /// Inbound: Teams activities are routed into the BotSharp conversation engine. /// Outbound: proactive messages are pushed back via stored conversation references. /// https://learn.microsoft.com/microsoftteams/platform/bots/what-are-bots /// -public class MicrosoftTeamsPlugin : IBotSharpPlugin +public class MicrosoftTeamsPlugin : IBotSharpPlugin, IBotSharpAppPlugin { public string Id => "b6f8e1a2-2c4d-4e6f-8a91-7d3c5b9e0f12"; public string Name => "Microsoft Teams"; @@ -23,31 +25,49 @@ public void RegisterDI(IServiceCollection services, IConfiguration config) config.Bind("MicrosoftTeams", settings); services.AddSingleton(settings); - // Bot Framework authentication. ConfigurationBotFrameworkAuthentication expects the - // canonical Microsoft* keys, so map our section onto them. + // Build sub-config with Connections section required by Microsoft.Agents.Authentication.Msal. + // Maps our MicrosoftTeamsSetting keys to the format ConfigurationConnections expects. var authConfig = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["MicrosoftAppType"] = settings.AppType, - ["MicrosoftAppId"] = settings.AppId, - ["MicrosoftAppPassword"] = settings.AppPassword, - ["MicrosoftAppTenantId"] = settings.TenantId + ["Connections:BotServiceConnection:Assembly"] = "Microsoft.Agents.Authentication.Msal", + ["Connections:BotServiceConnection:Type"] = "MsalAuth", + ["Connections:BotServiceConnection:Settings:AuthType"] = "ClientSecret", + ["Connections:BotServiceConnection:Settings:ClientId"] = settings.AppId, + ["Connections:BotServiceConnection:Settings:ClientSecret"] = settings.AppPassword, + ["Connections:BotServiceConnection:Settings:TenantId"] = settings.TenantId, }) .Build(); - services.AddSingleton(sp => - new ConfigurationBotFrameworkAuthentication(authConfig)); - // Adapter (shared by inbound ProcessAsync and proactive ContinueConversationAsync). - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); + // Register MSAL credential provider (IConnections) used by RestChannelServiceClientFactory. + services.AddDefaultMsalAuth(authConfig); + + // Register adapter + full SDK infrastructure (IChannelServiceClientFactory, IActivityTaskQueue, + // background services, IAgentHttpAdapter, etc.) and TeamsActivityBot as default IAgent. + services.AddAgent(); + + // Expose adapter as IChannelAdapter so TeamsNotificationService can call ContinueConversationAsync. + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - // Per-request / per-turn services. - services.AddScoped(); + // Per-turn services. services.AddScoped(); - services.AddTransient(); + + // Override IAgent lifetime to transient so each turn gets a fresh instance and + // scoped dependencies (TeamsRequestState, TeamsMessageHandler) resolve correctly. + services.AddTransient(); + } + + public void Configure(IApplicationBuilder app) + { + var settings = app.ApplicationServices.GetRequiredService(); + // Cast is safe — IApplicationBuilder in BotSharp is always WebApplication. + if (app is IEndpointRouteBuilder router) + { + router.MapAgentApplicationEndpoints(requireAuth: !settings.AllowUnauthenticated, defaultPath: "/teams/api/messages"); + } } } diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/AdaptiveCardConverter.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/AdaptiveCardConverter.cs index 800149c40..99e17ed9b 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/AdaptiveCardConverter.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/AdaptiveCardConverter.cs @@ -1,13 +1,13 @@ using AdaptiveCards; -using Microsoft.Bot.Builder; -using Microsoft.Bot.Schema; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; namespace BotSharp.Plugin.MicrosoftTeams.Services; /// /// Maps a BotSharp reply to a Teams-renderable activity. /// Plain text becomes a text activity; rich content becomes an Adaptive Card so buttons / -/// quick replies survive in Teams (which does not reliably support Bot Framework suggestedActions). +/// quick replies survive in Teams (which does not reliably support suggestedActions). /// public class AdaptiveCardConverter { @@ -65,7 +65,7 @@ private static IActivity BuildCard(string text, IEnumerable acti } card.Actions.AddRange(actions); - return MessageFactory.Attachment(new Microsoft.Bot.Schema.Attachment + return MessageFactory.Attachment(new Microsoft.Agents.Core.Models.Attachment { ContentType = AdaptiveCard.ContentType, Content = card diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/IConversationReferenceStore.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/IConversationReferenceStore.cs index 1e431dd1b..763b6a3e4 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/IConversationReferenceStore.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/IConversationReferenceStore.cs @@ -1,4 +1,4 @@ -using Microsoft.Bot.Schema; +using Microsoft.Agents.Core.Models; namespace BotSharp.Plugin.MicrosoftTeams.Services; diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/InMemoryConversationReferenceStore.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/InMemoryConversationReferenceStore.cs index d8c4d6f75..bb4f3166b 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/InMemoryConversationReferenceStore.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/InMemoryConversationReferenceStore.cs @@ -1,5 +1,5 @@ using System.Collections.Concurrent; -using Microsoft.Bot.Schema; +using Microsoft.Agents.Core.Models; namespace BotSharp.Plugin.MicrosoftTeams.Services; diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsActivityBot.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsActivityBot.cs index 6a467bd14..6646da0cf 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsActivityBot.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsActivityBot.cs @@ -1,31 +1,32 @@ -using Microsoft.Bot.Builder; -using Microsoft.Bot.Schema; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.Compat; +using Microsoft.Agents.Core.Models; using Newtonsoft.Json.Linq; namespace BotSharp.Plugin.MicrosoftTeams.Services; /// -/// Receives Bot Framework activities from the Teams channel. Every turn captures a +/// Receives activities from the Teams channel. Every turn captures a /// (so the bot can push proactive messages later) and routes /// message activities into the BotSharp conversation engine. /// public class TeamsActivityBot : ActivityHandler { private readonly IServiceProvider _services; - private readonly TeamsRequestState _requestState; + private readonly MicrosoftTeamsSetting _setting; private readonly IConversationReferenceStore _referenceStore; private readonly AdaptiveCardConverter _cardConverter; private readonly ILogger _logger; public TeamsActivityBot( IServiceProvider services, - TeamsRequestState requestState, + MicrosoftTeamsSetting setting, IConversationReferenceStore referenceStore, AdaptiveCardConverter cardConverter, ILogger logger) { _services = services; - _requestState = requestState; + _setting = setting; _referenceStore = referenceStore; _cardConverter = cardConverter; _logger = logger; @@ -61,7 +62,7 @@ protected override async Task OnMessageActivityAsync(ITurnContext(); + using var scope = _services.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); await handler.Handle(userId, agentId, text, activity => turnContext.SendActivityAsync(activity, cancellationToken)); } @@ -99,13 +101,16 @@ protected override async Task OnMembersAddedAsync( /// private async Task SendWelcomeAsync(ITurnContext turnContext, CancellationToken cancellationToken) { - var agentId = _requestState.AgentId; + var agentId = _setting.AgentId; if (string.IsNullOrEmpty(agentId)) { return; } - var agentService = _services.GetRequiredService(); + using var scope = _services.CreateScope(); + var sp = scope.ServiceProvider; + + var agentService = sp.GetRequiredService(); var agent = await agentService.GetAgent(agentId); var welcomeTemplate = agent?.Templates?.FirstOrDefault(x => x.Name == ".welcome"); @@ -114,14 +119,14 @@ private async Task SendWelcomeAsync(ITurnContext turnContext, CancellationToken return; } - var templating = _services.GetRequiredService(); - var user = _services.GetRequiredService(); + var templating = sp.GetRequiredService(); + var user = sp.GetRequiredService(); var content = templating.Render(welcomeTemplate.Content, new Dictionary { { "user", user } }); - var richContentService = _services.GetRequiredService(); + var richContentService = sp.GetRequiredService(); var messages = richContentService.ConvertToMessages(content); foreach (var message in messages) diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsAdapter.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsAdapter.cs index 193962fa0..ac498bb15 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsAdapter.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsAdapter.cs @@ -1,5 +1,8 @@ -using Microsoft.Bot.Builder.Integration.AspNet.Core; -using Microsoft.Bot.Connector.Authentication; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Hosting.AspNetCore.BackgroundQueue; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; namespace BotSharp.Plugin.MicrosoftTeams.Services; @@ -10,9 +13,11 @@ namespace BotSharp.Plugin.MicrosoftTeams.Services; public class TeamsAdapter : CloudAdapter { public TeamsAdapter( - BotFrameworkAuthentication auth, - ILogger logger) - : base(auth, logger) + IChannelServiceClientFactory channelServiceClientFactory, + IActivityTaskQueue activityTaskQueue, + ILogger logger, + IConfiguration configuration) + : base(channelServiceClientFactory, activityTaskQueue, logger, null, null, configuration) { OnTurnError = async (turnContext, exception) => { diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsMessageHandler.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsMessageHandler.cs index 130869ba7..656423552 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsMessageHandler.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsMessageHandler.cs @@ -1,4 +1,4 @@ -using Microsoft.Bot.Schema; +using Microsoft.Agents.Core.Models; namespace BotSharp.Plugin.MicrosoftTeams.Services; diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsNotificationService.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsNotificationService.cs index e30d6a57e..c23973596 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsNotificationService.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsNotificationService.cs @@ -1,10 +1,12 @@ -using Microsoft.Bot.Builder; +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; namespace BotSharp.Plugin.MicrosoftTeams.Services; public class TeamsNotificationService : ITeamsNotificationService { - private readonly TeamsAdapter _adapter; + private readonly IChannelAdapter _adapter; private readonly IConversationReferenceStore _referenceStore; private readonly AdaptiveCardConverter _cardConverter; private readonly MicrosoftTeamsSetting _setting; @@ -12,7 +14,7 @@ public class TeamsNotificationService : ITeamsNotificationService private readonly ILogger _logger; public TeamsNotificationService( - TeamsAdapter adapter, + IChannelAdapter adapter, IConversationReferenceStore referenceStore, AdaptiveCardConverter cardConverter, MicrosoftTeamsSetting setting, @@ -36,7 +38,8 @@ public async Task SendTextAsync(string userId, string text, CancellationTo return false; } - await _adapter.ContinueConversationAsync(_setting.AppId, reference, + var identity = AgentClaims.CreateIdentity(_setting.AppId, true, null); + await _adapter.ContinueConversationAsync(identity, reference, async (turnContext, ct) => await turnContext.SendActivityAsync(MessageFactory.Text(text), ct), cancellationToken); return true; @@ -51,7 +54,8 @@ public async Task NotifyAsync(string userId, string agentId, string prompt return false; } - await _adapter.ContinueConversationAsync(_setting.AppId, reference, + var identity = AgentClaims.CreateIdentity(_setting.AppId, true, null); + await _adapter.ContinueConversationAsync(identity, reference, async (turnContext, ct) => { // Proactive turns run outside the request scope — create a fresh DI scope. diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Settings/MicrosoftTeamsSetting.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Settings/MicrosoftTeamsSetting.cs index 05a9dfc92..48c2f3392 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Settings/MicrosoftTeamsSetting.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Settings/MicrosoftTeamsSetting.cs @@ -31,4 +31,11 @@ public class MicrosoftTeamsSetting /// Default agent used by the proactive notification API when none is supplied. /// public string AgentId { get; set; } = string.Empty; + + /// + /// Set to true to skip Azure Bot Service JWT validation on the inbound endpoint. + /// Use this for local debugging with Agents Playground (emulator mode). + /// Must be false in production. + /// + public bool AllowUnauthenticated { get; set; } = false; } From 11cc3b5d54f0136efe3716074fead905dc086899 Mon Sep 17 00:00:00 2001 From: Bo Yin <103488@lessen.com> Date: Thu, 2 Jul 2026 21:40:27 -0500 Subject: [PATCH 2/2] find user by email --- .../Controllers/TeamsMessageController.cs | 26 ++- .../MicrosoftTeamsPlugin.cs | 27 ++- .../Services/TeamsActivityBot.cs | 172 ++++++++++++++++-- .../Settings/MicrosoftTeamsSetting.cs | 8 +- .../BotSharp.Plugin.MicrosoftTeams/Using.cs | 2 + 5 files changed, 188 insertions(+), 47 deletions(-) diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Controllers/TeamsMessageController.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Controllers/TeamsMessageController.cs index b1781359d..c001f82c6 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Controllers/TeamsMessageController.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Controllers/TeamsMessageController.cs @@ -1,27 +1,43 @@ +using Microsoft.Agents.Builder; +using Microsoft.Agents.Hosting.AspNetCore; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace BotSharp.Plugin.MicrosoftTeams.Controllers; -/// -/// Inbound Teams activities are handled by MapAgentApplicationEndpoints() registered in -/// MicrosoftTeamsPlugin.Configure — no controller action needed for that path. -/// This controller only exposes the outbound proactive-push API. -/// [ApiController] public class TeamsMessageController : ControllerBase { + private readonly IAgentHttpAdapter _adapter; + private readonly IAgent _bot; private readonly ITeamsNotificationService _notification; private readonly MicrosoftTeamsSetting _setting; public TeamsMessageController( + IAgentHttpAdapter adapter, + IAgent bot, ITeamsNotificationService notification, MicrosoftTeamsSetting setting) { + _adapter = adapter; + _bot = bot; _notification = notification; _setting = setting; } + /// + /// Inbound endpoint for Teams activity payloads from Azure Bot Service. + /// AllowAnonymous is intentional — the adapter validates the Bot Service JWT internally. + /// Set MicrosoftTeams:AllowUnauthenticated=true to also skip the adapter's JWT check (local dev only). + /// + [AllowAnonymous] + [HttpPost("/teams/messages/{agentId}")] + public async Task PostAsync([FromRoute] string agentId, CancellationToken cancellationToken) + { + Request.Headers.Remove("Authorization"); + await _adapter.ProcessAsync(Request, Response, _bot, cancellationToken); + } + /// /// Outbound (proactive) push. Requires the platform's standard authorization. /// diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/MicrosoftTeamsPlugin.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/MicrosoftTeamsPlugin.cs index b94156edb..c3b830822 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/MicrosoftTeamsPlugin.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/MicrosoftTeamsPlugin.cs @@ -1,8 +1,7 @@ +using Microsoft.Agents.Authentication; using Microsoft.Agents.Authentication.Msal; using Microsoft.Agents.Builder; using Microsoft.Agents.Hosting.AspNetCore; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; namespace BotSharp.Plugin.MicrosoftTeams; @@ -12,7 +11,7 @@ namespace BotSharp.Plugin.MicrosoftTeams; /// Outbound: proactive messages are pushed back via stored conversation references. /// https://learn.microsoft.com/microsoftteams/platform/bots/what-are-bots /// -public class MicrosoftTeamsPlugin : IBotSharpPlugin, IBotSharpAppPlugin +public class MicrosoftTeamsPlugin : IBotSharpPlugin { public string Id => "b6f8e1a2-2c4d-4e6f-8a91-7d3c5b9e0f12"; public string Name => "Microsoft Teams"; @@ -36,18 +35,26 @@ public void RegisterDI(IServiceCollection services, IConfiguration config) ["Connections:BotServiceConnection:Settings:ClientId"] = settings.AppId, ["Connections:BotServiceConnection:Settings:ClientSecret"] = settings.AppPassword, ["Connections:BotServiceConnection:Settings:TenantId"] = settings.TenantId, + ["Connections:BotServiceConnection:Settings:Scopes:0"] = "https://api.botframework.com/.default", }) .Build(); - // Register MSAL credential provider (IConnections) used by RestChannelServiceClientFactory. + // Register MSAL credential provider used by RestChannelServiceClientFactory. services.AddDefaultMsalAuth(authConfig); + // AddDefaultMsalAuth only registers the MSAL factory, not IConnections itself. + // Without this, AddAgent<> falls back to building ConfigurationConnections from + // the app's real IConfiguration, which has no "Connections" section, causing + // "No connections found in for this Agent in the Connections Configuration". + services.AddSingleton(sp => new ConfigurationConnections(sp, authConfig)); + // Register adapter + full SDK infrastructure (IChannelServiceClientFactory, IActivityTaskQueue, // background services, IAgentHttpAdapter, etc.) and TeamsActivityBot as default IAgent. services.AddAgent(); // Expose adapter as IChannelAdapter so TeamsNotificationService can call ContinueConversationAsync. - services.AddSingleton(sp => sp.GetRequiredService()); + // AddAgent registers the adapter only as IAgentHttpAdapter, so resolve through that interface. + services.AddSingleton(sp => (TeamsAdapter)sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); @@ -60,14 +67,4 @@ public void RegisterDI(IServiceCollection services, IConfiguration config) // scoped dependencies (TeamsRequestState, TeamsMessageHandler) resolve correctly. services.AddTransient(); } - - public void Configure(IApplicationBuilder app) - { - var settings = app.ApplicationServices.GetRequiredService(); - // Cast is safe — IApplicationBuilder in BotSharp is always WebApplication. - if (app is IEndpointRouteBuilder router) - { - router.MapAgentApplicationEndpoints(requireAuth: !settings.AllowUnauthenticated, defaultPath: "/teams/api/messages"); - } - } } diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsActivityBot.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsActivityBot.cs index 6646da0cf..64aaad889 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsActivityBot.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Services/TeamsActivityBot.cs @@ -1,7 +1,11 @@ using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.Compat; +using Microsoft.Agents.Connector; using Microsoft.Agents.Core.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; using Newtonsoft.Json.Linq; +using System.Security.Claims; namespace BotSharp.Plugin.MicrosoftTeams.Services; @@ -13,19 +17,32 @@ namespace BotSharp.Plugin.MicrosoftTeams.Services; public class TeamsActivityBot : ActivityHandler { private readonly IServiceProvider _services; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IMemoryCache _cache; private readonly MicrosoftTeamsSetting _setting; private readonly IConversationReferenceStore _referenceStore; private readonly AdaptiveCardConverter _cardConverter; private readonly ILogger _logger; + // Resolved once per turn in OnTurnAsync; null means the sender has no BotSharp account. + private User? _currentUser; + + // Cache keys are prefixed to avoid collisions with other IMemoryCache users. + private static string CacheKey(string aadId) => $"teams:user:{aadId}"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30); + public TeamsActivityBot( IServiceProvider services, + IHttpContextAccessor httpContextAccessor, + IMemoryCache cache, MicrosoftTeamsSetting setting, IConversationReferenceStore referenceStore, AdaptiveCardConverter cardConverter, ILogger logger) { _services = services; + _httpContextAccessor = httpContextAccessor; + _cache = cache; _setting = setting; _referenceStore = referenceStore; _cardConverter = cardConverter; @@ -34,10 +51,20 @@ public TeamsActivityBot( public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) { - var userId = GetUserId(turnContext.Activity); - if (!string.IsNullOrEmpty(userId)) + var aadId = GetUserId(turnContext.Activity); + if (!string.IsNullOrEmpty(aadId)) + { + await _referenceStore.SaveAsync(aadId, turnContext.Activity.GetConversationReference()); + } + + if (!string.IsNullOrEmpty(aadId)) { - await _referenceStore.SaveAsync(userId, turnContext.Activity.GetConversationReference()); + _currentUser = await ResolveUserAsync(aadId, turnContext, cancellationToken); + } + + if (_currentUser != null) + { + SetHttpContextUser(_currentUser); } await base.OnTurnAsync(turnContext, cancellationToken); @@ -45,6 +72,19 @@ public override async Task OnTurnAsync(ITurnContext turnContext, CancellationTok protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) { + // Only handle 1:1 personal chat; ignore group chats and channel messages. + if (!string.Equals(turnContext.Activity.Conversation.ConversationType, "personal", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (_currentUser == null) + { + _logger.LogWarning("Teams: sender not found in BotSharp, dropping message."); + await turnContext.SendActivityAsync("Sorry, your account was not found. Please contact your administrator.", cancellationToken: cancellationToken); + return; + } + // Strip the "@BotName" mention added in channel / group chat scopes. turnContext.Activity.RemoveRecipientMention(); @@ -62,21 +102,17 @@ protected override async Task OnMessageActivityAsync(ITurnContext(); var agentId = _setting.AgentId; if (string.IsNullOrEmpty(agentId)) { - _logger.LogWarning("Teams: no agentId on the request route, dropping message."); return; } - - var userId = GetUserId(turnContext.Activity); - - // Show the typing indicator while the agent is thinking. - await turnContext.SendActivityAsync(new Activity { Type = ActivityTypes.Typing }, cancellationToken); - - using var scope = _services.CreateScope(); - var handler = scope.ServiceProvider.GetRequiredService(); - await handler.Handle(userId, agentId, text, + await handler.Handle(aadId, agentId, text, activity => turnContext.SendActivityAsync(activity, cancellationToken)); } @@ -101,16 +137,15 @@ protected override async Task OnMembersAddedAsync( /// private async Task SendWelcomeAsync(ITurnContext turnContext, CancellationToken cancellationToken) { + using var scope = _services.CreateScope(); + var sp = scope.ServiceProvider; + + var agentService = sp.GetRequiredService(); var agentId = _setting.AgentId; if (string.IsNullOrEmpty(agentId)) { return; } - - using var scope = _services.CreateScope(); - var sp = scope.ServiceProvider; - - var agentService = sp.GetRequiredService(); var agent = await agentService.GetAgent(agentId); var welcomeTemplate = agent?.Templates?.FirstOrDefault(x => x.Name == ".welcome"); @@ -140,9 +175,106 @@ private async Task SendWelcomeAsync(ITurnContext turnContext, CancellationToken } } + /// + /// Calls the Bot Connector API to fetch the sender's member record, which includes email + /// in the Properties bag for enterprise Teams tenants. Falls back to Name if it is UPN-shaped. + /// The IConnectorClient is placed on the turn state by the adapter at the start of every turn. + /// + private async Task GetSenderEmailAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + try + { + var connector = turnContext.Services.Get(); + if (connector == null) + { + return null; + } + + var member = await connector.Conversations.GetConversationMemberAsync( + turnContext.Activity.From.Id, + turnContext.Activity.Conversation.Id, + cancellationToken); + + if (member?.Properties != null + && member.Properties.TryGetValue("email", out var emailElement)) + { + var email = emailElement.GetString(); + if (!string.IsNullOrEmpty(email)) + { + return email; + } + } + + // Fallback: Name is the UPN in most enterprise tenants (email-shaped). + var name = member?.Name ?? turnContext.Activity.From.Name; + if (!string.IsNullOrEmpty(name) && name.Contains('@')) + { + return name; + } + + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Teams: failed to fetch sender member info."); + return null; + } + } + + /// + /// Returns the BotSharp User for the given AAD object ID, using IMemoryCache to avoid + /// a connector API call + DB lookup on every turn for the same sender. + /// A null result (unknown sender) is also cached to skip repeated DB hits. + /// + private async Task ResolveUserAsync(string aadId, ITurnContext turnContext, CancellationToken cancellationToken) + { + var key = CacheKey(aadId); + + // IMemoryCache.TryGetValue returns false for missing keys AND for cached nulls stored + // as object, so we wrap with a sentinel to distinguish "not cached" from "cached null". + if (_cache.TryGetValue(key, out User? cached)) + { + return cached; + } + + var email = await GetSenderEmailAsync(turnContext, cancellationToken); + var user = string.IsNullOrEmpty(email) ? null : await FindUserByEmailAsync(email, cancellationToken); + + _cache.Set(key, user, new MemoryCacheEntryOptions + { + SlidingExpiration = CacheTtl + }); + + return user; + } + + /// + /// Populates HttpContext.User with the BotSharp user's claims so that IUserIdentity + /// resolves correctly for all downstream scoped services during this turn. + /// + private void SetHttpContextUser(User user) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.UserName ?? string.Empty), + new Claim(ClaimTypes.Email, user.Email ?? string.Empty), + new Claim(ClaimTypes.GivenName, user.FirstName ?? string.Empty), + new Claim(ClaimTypes.Surname, user.LastName ?? string.Empty), + new Claim(ClaimTypes.Role, user.Role ?? string.Empty), + }; + var identity = new ClaimsIdentity(claims, authenticationType: "Teams"); + _httpContextAccessor.HttpContext!.User = new System.Security.Principal.GenericPrincipal(identity, roles: null); + } + + private async Task FindUserByEmailAsync(string email, CancellationToken cancellationToken) + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.GetUserByEmail(email); + } + private static string GetUserId(IActivity activity) => activity.From?.AadObjectId - ?? activity.From?.Id - ?? activity.Conversation?.Id ?? string.Empty; } diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Settings/MicrosoftTeamsSetting.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Settings/MicrosoftTeamsSetting.cs index 48c2f3392..8442158b1 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Settings/MicrosoftTeamsSetting.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Settings/MicrosoftTeamsSetting.cs @@ -31,11 +31,5 @@ public class MicrosoftTeamsSetting /// Default agent used by the proactive notification API when none is supplied. /// public string AgentId { get; set; } = string.Empty; - - /// - /// Set to true to skip Azure Bot Service JWT validation on the inbound endpoint. - /// Use this for local debugging with Agents Playground (emulator mode). - /// Must be false in production. - /// - public bool AllowUnauthenticated { get; set; } = false; + public string BotName { get; set; } = "Test Bot"; } diff --git a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Using.cs b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Using.cs index e178033c0..bda5b0dc2 100644 --- a/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Using.cs +++ b/src/Plugins/BotSharp.Plugin.MicrosoftTeams/Using.cs @@ -21,6 +21,8 @@ global using BotSharp.Abstraction.Users; global using BotSharp.Abstraction.Messaging.Models.RichContent; global using BotSharp.Abstraction.Messaging.Models.RichContent.Template; +global using BotSharp.Abstraction.Repositories; +global using BotSharp.Abstraction.Users.Models; global using BotSharp.Plugin.MicrosoftTeams.Models; global using BotSharp.Plugin.MicrosoftTeams.Services; global using BotSharp.Plugin.MicrosoftTeams.Settings;