diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 3ded3ff3..60cb7422 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -10,3 +10,4 @@ |Copilot Studio Client|Console app to consume a Copilot Studio Agent|[copilotstudio-client](copilotstudio-client/README.md)| |Copilot Studio Skill |Call the echo bot from a Copilot Studio skill |[copilotstudio-skill](copilotstudio-skill/README.md)| |RetrievalBot Sample with Semantic Kernel|A simple Retrieval Agent that is hosted on an Asp.net core web service. |[RetrievalBot](RetrievalBot/README.md)| +| Praoctive Messaging |create a new conversation or send a proactive message into an existing user–agent conversation in Microsoft Teams. |[proactive-messaging](proactive-messaging/README.md)| \ No newline at end of file diff --git a/samples/dotnet/proactive-messaging/AspNetExtensions.cs b/samples/dotnet/proactive-messaging/AspNetExtensions.cs new file mode 100644 index 00000000..89b977ae --- /dev/null +++ b/samples/dotnet/proactive-messaging/AspNetExtensions.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Core; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation") + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + + if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true)) + { + System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled"); + return; + } + + services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!); + } + + public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions) + { + AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions)); + + if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId"); + } + + foreach (var audience in validationOptions.Audiences) + { + if (!Guid.TryParse(audience, out _)) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID"); + } + } + + if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0) + { + validationOptions.ValidIssuers = + [ + "https://api.botframework.com", + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", + ]; + + if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _)) + { + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId)); + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId)); + } + } + + if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl)) + { + validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl)) + { + validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl; + } + + var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval; + + _ = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + ValidIssuers = validationOptions.ValidIssuers, + ValidAudiences = validationOptions.Audiences, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + }; + + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + string[] parts = authorizationHeader?.Split(' ')!; + if (parts.Length != 2 || parts[0] != "Bearer") + { + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + JwtSecurityToken token = new(parts[1]); + string issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value!; + + if (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.AzureBotServiceOpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + else + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.OpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + }, + OnTokenValidated = context => Task.CompletedTask, + OnForbidden = context => Task.CompletedTask, + OnAuthenticationFailed = context => Task.CompletedTask + }; + }); + } + + public class TokenValidationOptions + { + public IList? Audiences { get; set; } + public string? TenantId { get; set; } + public IList? ValidIssuers { get; set; } + public bool IsGov { get; set; } = false; + public string? AzureBotServiceOpenIdMetadataUrl { get; set; } + public string? OpenIdMetadataUrl { get; set; } + public bool AzureBotServiceTokenHandling { get; set; } = true; + public TimeSpan? OpenIdMetadataRefresh { get; set; } + } +} diff --git a/samples/dotnet/proactive-messaging/ProactiveMessaging.csproj b/samples/dotnet/proactive-messaging/ProactiveMessaging.csproj new file mode 100644 index 00000000..2a925323 --- /dev/null +++ b/samples/dotnet/proactive-messaging/ProactiveMessaging.csproj @@ -0,0 +1,11 @@ + + + net8.0 + latest + enable + + + + + + diff --git a/samples/dotnet/proactive-messaging/ProactiveMessenger.cs b/samples/dotnet/proactive-messaging/ProactiveMessenger.cs new file mode 100644 index 00000000..fed807cd --- /dev/null +++ b/samples/dotnet/proactive-messaging/ProactiveMessenger.cs @@ -0,0 +1,103 @@ +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Core.Models; +using Microsoft.Extensions.Options; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ProactiveMessaging +{ + public class ProactiveMessagingOptions + { + public string BotId { get; set; } = string.Empty; + public string AgentId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string UserAadObjectId { get; set; } = string.Empty; + public string Scope { get; set; } = "https://api.botframework.com/.default"; + public string ChannelId { get; set; } = "msteams"; + public string ServiceUrl { get; set; } = "https://smba.trafficmanager.net/teams/"; + } + + public class ProactiveMessenger : AgentApplication + { + private readonly ProactiveMessagingOptions _options; + private readonly IChannelAdapter _adapter; + + public ProactiveMessenger(AgentApplicationOptions appOptions, IChannelAdapter adapter, IOptions options) : base(appOptions) + { + _options = options.Value; + _adapter = adapter; + // No inbound routes registered; this agent exists to enable proactive operations. + } + + + // New helper to create a conversation and return its id (optionally sending an initial message) + public async Task CreateConversationAsync(string? message, string? userAadObjectId, CancellationToken cancellationToken) + { + string? createdConversationId = null; + var effectiveUserId = string.IsNullOrWhiteSpace(userAadObjectId) ? _options.UserAadObjectId : userAadObjectId; + + var parameters = new ConversationParameters(false, + agent: new ChannelAccount(id: _options.AgentId), + tenantId: _options.TenantId, + members: new List + { + new ChannelAccount { Id = effectiveUserId } + }); + + await _adapter.CreateConversationAsync( + _options.BotId, + _options.ChannelId, + _options.ServiceUrl, + _options.Scope, + parameters, + async (ITurnContext turnContext, CancellationToken ct) => + { + createdConversationId = turnContext.Activity.Conversation.Id; + if (!string.IsNullOrWhiteSpace(message)) + { + await turnContext.SendActivityAsync(MessageFactory.Text(message), ct); + } + }, + default); + + if (string.IsNullOrEmpty(createdConversationId)) + { + throw new System.InvalidOperationException("Failed to obtain conversation id after creation."); + } + + return createdConversationId; + } + + // Send a message to an existing conversation id + public async Task SendMessageToConversationAsync(string conversationId, string message, CancellationToken cancellationToken) + { + var reference = BuildConversationReference(conversationId, null); + await _adapter.ContinueConversationAsync( + _options.BotId, + reference, + async (ITurnContext turnContext, CancellationToken ct) => + { + await turnContext.SendActivityAsync(MessageFactory.Text(message), ct); + }, + default); + } + + private ConversationReference BuildConversationReference(string conversationId, ConversationReference? existing) + { + if (existing != null) + { + return existing; + } + + return new ConversationReference + { + Agent = new ChannelAccount { Id = _options.AgentId }, + Conversation = new ConversationAccount { Id = conversationId }, + ServiceUrl = _options.ServiceUrl + }; + } + + } +} diff --git a/samples/dotnet/proactive-messaging/README.MD b/samples/dotnet/proactive-messaging/README.MD new file mode 100644 index 00000000..57a992df --- /dev/null +++ b/samples/dotnet/proactive-messaging/README.MD @@ -0,0 +1,76 @@ +# Microsoft Copilot Studio Teams Proactive Messaging Sample + +This sample shows how to (1) create a new conversation or (2) send a proactive message into an existing user–agent conversation in Microsoft Teams using the Microsoft 365 Agents SDK. + +> **Important:** Proactive messaging is only supported on the Microsoft Teams channel. + +## Proactive Messaging Scenarios +A proactive message is any message a Copilot Studio agent sends that is not directly triggered by the user’s current input. Common scenarios: + +- **Notify in an existing conversation:** Push alerts or status updates into an active user–agent Teams chat or channel thread. +- **Relay external events:** Forward events or external system notifications back into the ongoing conversation with the user. +- **Start a new conversation:** Initiate a fresh 1:1 (or channel) conversation when a monitored condition or business rule is met. + +## Prerequisites + +- [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) +- [Microsoft 365 Agents Toolkit](https://github.com/OfficeDev/microsoft-365-agents-toolkit) +- Copilot Studio Agent published to the Microsoft Teams channel ([publish guide](https://learn.microsoft.com/en-us/microsoft-copilot-studio/publication-add-bot-to-microsoft-teams)) + +## Run the Sample Locally + +1. Restore and build the project: + ```bash + cd samples/dotnet/proactive-messaging + dotnet build + ``` +2. Run the sample (Kestrel is bound to `http://localhost:5199` when `ASPNETCORE_ENVIRONMENT=Development`): + ```bash + dotnet run + ``` +3. Create a new conversation: + > **Note:** `UserAadObjectId` is the user's Object ID from Microsoft Entra ID (Azure AD).s + + ```bash + curl -X POST http://localhost:5199/api/createconversation \ + -H 'Content-Type: application/json' \ + -d '{ + "Message": "Hello from proactive sample!", + "UserAadObjectId": "00000000-0000-0000-0000-000000000123" + }' + ``` + Example response: + ```json + { "conversationId": "" } + ``` +4. Send another proactive message to that conversation: + > **Note:** `ConversationId` is the value returned by the create conversation step above. + + ```bash + curl -X POST http://localhost:5199/api/sendmessage \ + -H 'Content-Type: application/json' \ + -d '{ + "ConversationId": "", + "Message": "Proactive Message" + }' + ``` + +## Enabling JWT Token Validation + +By default, JWT token validation is disabled to simplify local debugging. To enable it, update `appsettings.json`: + +```json +"TokenValidation": { + "Enabled": true, + "Audiences": [ + "{{ClientId}}" // Client ID (Application ID) of the Azure AD app / Bot registration + ], + "TenantId": "{{TenantId}}" +} +``` + +When enabled, requests to the proactive endpoints must include a valid bearer token issued for one of the configured audiences. + +## Further Reading + +To learn more about building agents, see the [Microsoft 365 Agents SDK documentation](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/). diff --git a/samples/dotnet/proactive-messaging/RequestModels.cs b/samples/dotnet/proactive-messaging/RequestModels.cs new file mode 100644 index 00000000..ccc622c9 --- /dev/null +++ b/samples/dotnet/proactive-messaging/RequestModels.cs @@ -0,0 +1,4 @@ +namespace ProactiveMessaging; + +public record CreateConversationRequest(string? Message, string? UserAadObjectId); +public record SendMessageRequest(string ConversationId, string Message); diff --git a/samples/dotnet/proactive-messaging/appsettings.json b/samples/dotnet/proactive-messaging/appsettings.json new file mode 100644 index 00000000..b6218c6a --- /dev/null +++ b/samples/dotnet/proactive-messaging/appsettings.json @@ -0,0 +1,57 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "TokenValidation": { + "Enabled": false, + "Audiences": [ + "{{ClientId}}" + ], + "TenantId": "{{TenantId}}" + }, + "Connections": { + "ProactiveMessagingConnection": { + // Connection used by the Agent SDK to acquire tokens for proactive operations + "ConnectionType": "AzureAD", + "Settings": { + // Azure AD Tenant (Directory) Id + "TenantId": "", + // Azure AD App Registration Client Id for MS Copilot Studio Agent + "ClientId": "", + // Client secret for the above app registration(MS Copilot Studio Agent ID) + "ClientSecret": "", + // Scopes required to call Bot Framework service; usually leave default + "Scopes": [ + "https://api.botframework.com/.default" + ] + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "https://smba*", + "Connection": "ProactiveMessagingConnection" + } + ], + "ProactiveMessaging": { + // MS Copilot Studio Agent Id in published Teams Channel + "BotId": "28:", + // MS Copilot Studio Agent Id + "AgentId": "", + // Tenant where the agent and user reside + "TenantId": "", + // OAuth scope (leave default) + "Scope": "https://api.botframework.com/.default", + // Target channel id (msteams for Microsoft Teams) + "ChannelId": "msteams", + // Base service URL for Teams; region-specific URLs acceptable but generic works + // Public: https://smba.trafficmanager.net/teams/ + // GCC: https://smba.infra.gcc.teams.microsoft.com/teams + // GCC High: https://smba.infra.gov.teams.microsoft.us/teams + // DoD: https://smba.infra.dod.teams.microsoft.us/teams + "ServiceUrl": "https://smba.trafficmanager.net///" + } +} diff --git a/samples/dotnet/proactive-messaging/program.cs b/samples/dotnet/proactive-messaging/program.cs new file mode 100644 index 00000000..42c20aae --- /dev/null +++ b/samples/dotnet/proactive-messaging/program.cs @@ -0,0 +1,79 @@ +using ProactiveMessaging; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using System.Threading; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +var builder = WebApplication.CreateBuilder(args); + +// In development, pin a consistent local port for easier testing. +// In other environments, allow standard hosting configuration / environment variables. +if (builder.Environment.IsDevelopment()) +{ + builder.WebHost.UseUrls("http://localhost:5199"); +} + +builder.Services.AddHttpClient(); + +// Register agent infrastructure and proactive messenger agent +builder.AddAgentApplicationOptions(); +builder.AddAgent(); +// Bind and apply token validation explicitly (avoids silent early-exit if section missing) +var tokenValidationOpts = builder.Configuration.GetSection("TokenValidation").Get(); +if (tokenValidationOpts != null && (tokenValidationOpts.Audiences?.Count ?? 0) > 0) +{ + builder.Services.AddAgentAspNetAuthentication(tokenValidationOpts); +} +builder.Services.AddAuthorization(); + +// Bind ProactiveMessaging options (ProactiveMessaging section in config) +builder.Services.Configure(builder.Configuration.GetSection("ProactiveMessaging")); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/", () => "Proactive Messaging Sample"); + + +// Create a new conversation (optionally with an initial message) and return its id +app.MapPost("/api/createconversation", async (HttpRequest request, HttpResponse response, ProactiveMessenger messenger, CancellationToken cancellationToken) => +{ + var body = await request.ReadFromJsonAsync(cancellationToken); + var conversationId = await messenger.CreateConversationAsync(body?.Message, body?.UserAadObjectId, cancellationToken); + return Results.Created($"/api/conversations/{conversationId}", new + { + conversationId, + status = "Created", + }); + +}); + +// Send a message to an existing conversation id +app.MapPost("/api/sendmessage", async (HttpRequest request, HttpResponse response, ProactiveMessenger messenger, CancellationToken cancellationToken) => +{ + var body = await request.ReadFromJsonAsync(cancellationToken); + if (body == null || string.IsNullOrWhiteSpace(body.ConversationId) || string.IsNullOrWhiteSpace(body.Message)) + { + return Results.BadRequest(new + { + status = "Error", + error = new { code = "Validation", message = "conversationId and message are required" } + }); + } + + await messenger.SendMessageToConversationAsync(body.ConversationId, body.Message, cancellationToken); + return Results.Ok(new + { + conversationId = body.ConversationId, + status = "Delivered" + }); +}); + +app.Run();