Skip to content
Open
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 samples/dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)|
175 changes: 175 additions & 0 deletions samples/dotnet/proactive-messaging/AspNetExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<string, ConfigurationManager<OpenIdConnectConfiguration>> _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<TokenValidationOptions>()!);
}

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<OpenIdConnectConfiguration>(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient())
{
AutomaticRefreshInterval = openIdMetadataRefresh
};
});
}
else
{
context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key =>
{
return new ConfigurationManager<OpenIdConnectConfiguration>(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<string>? Audiences { get; set; }
public string? TenantId { get; set; }
public IList<string>? 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; }
}
}
11 changes: 11 additions & 0 deletions samples/dotnet/proactive-messaging/ProactiveMessaging.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.Authentication.Msal" Version="1.2.*-*" />
<PackageReference Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.2.*-*" />
</ItemGroup>
</Project>
103 changes: 103 additions & 0 deletions samples/dotnet/proactive-messaging/ProactiveMessenger.cs
Original file line number Diff line number Diff line change
@@ -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<ProactiveMessagingOptions> 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<string> 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<ChannelAccount>
{
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
};
}

}
}
76 changes: 76 additions & 0 deletions samples/dotnet/proactive-messaging/README.MD
Original file line number Diff line number Diff line change
@@ -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": "<conversation-id>" }
```
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": "<conversation-id>",
"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/).
4 changes: 4 additions & 0 deletions samples/dotnet/proactive-messaging/RequestModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace ProactiveMessaging;

public record CreateConversationRequest(string? Message, string? UserAadObjectId);
public record SendMessageRequest(string ConversationId, string Message);
Loading