How do I catch bad data before it derails my agent?
August 7, 2025QTip: Maintenance plan for performance issues demo
August 7, 2025Prerequisite
This article assumes that you’ve already gone through a following post. Please make sure to read it before proceeding:
With the article, we have found how to publish a Custom Engine Agent using pro-code approaches such as C#. In this post, I’d like to shift the focus to security, specifically how to protect the endpoint of our custom Microsoft 365 Copilot. Through several architectural explorations, we found an approach that seems to work well. However, I strongly encourage you to review and evaluate it carefully for your production environment.
Which Endpoints Can Be Controlled?
In the current architecture, there are three key endpoints to consider from a security perspective:
- Teams Endpoint
This is the entry point where users interact with the Custom Engine Agent through Microsoft Teams. - Azure Bot Service Endpoint
This is the publicly accessible endpoint provided by Azure Bot Service that relays messages between Teams and your bot backend. - ASP.NET Core Endpoint
In the previous article, we used a local devtunnel for development purposes. In a production environment, however, this would likely be hosted on Azure App Service or others.
Each of these endpoints may require different protection strategies, which we’ll explore in the following sections.
1. Controlling the Teams Endpoint
When it comes to the Teams endpoint, control ultimately comes down to Teams app management within your Microsoft 365 tenant. Specifically, the manifest file for your custom Teams app (i.e., the Custom Agent) needs to be uploaded in your tenant, and access is governed via the Teams Admin Center. This isn’t about controlling the endpoint, but rather about limiting who can access the app. You can restrict access on a per-user or per-group basis, effectively preventing malicious users inside your organization from using the app. However, you cannot restrict access at the endpoint level, nor could you prevent a malicious external organization from copying the app package. This limitation may pose a concern, especially when thinking about endpoint-level security outside your tenant’s control.
2. Controlling the Azure Bot Service Endpoint
The Azure Bot Service endpoint acts as a bridge between the Teams Channel and your pro-code backend. Here, the only available security configuration is to specify the Service Principal that the agent uses. There isn’t much room for granular control here—it’s essentially a relay point managed by Azure Bot Service, and protection depends largely on how you secure the endpoints it connects to.
3. Controlling the ASP.NET Core Endpoint
This is where endpoint protection becomes critical. When you configure your bot in Azure Bot Service, you must expose your pro-code endpoint to the public internet. In our earlier article, we used a local devtunnel for development. But in production, you’ll likely use Azure App Service or others, which results in a publicly accessible endpoint. While Microsoft provides documentation on network isolation options for Azure Bot Service, these are currently only supported when using the Direct Line channel – not the Teams channel. This means that when using Teams as the entry point, you cannot isolate the backend endpoint via a private network, making it critical to implement other security measures at the app level (e.g., token validation, IP restrictions, mutual TLS, etc.).
Let’s Review Other Articles on this Topic
There are several valuable resources that describe this topic. Since Microsoft Teams is a SaaS application, the bot endpoint (e.g., https://my-webapp-endpoint.net/api/messages) must be publicly accessible when integrated through the Teams channel.
- Is it possible to integrate Azure Bot with Teams without public access?
- How to create Azure Bot Service in a private network?
In particular, this article provides an excellent deep dive into the traffic flow between Teams and Azure Bot Service:
In the section titled “Challenge 2: Network isolation vs. Teams connectivity,” the article clearly explains why network-level isolation is fundamentally incompatible with the Teams channel. The article also outlines a practical security approach using Azure Firewall, NSG (Network Security Groups), and JWT token validation at the application level.
If you’re using the Teams channel, complete network isolation is not feasible—which makes sense, given that Teams itself is a SaaS platform and cannot be brought into your private network. As a result, protecting the backend bot (e.g., the ASP.NET Core endpoint) will require application-level controls, particularly JWT token validation to ensure that only trusted sources can invoke the bot.
Let’s now take a closer look at how to implement that in C#.
Controlling Endpoints in the ASP.NET Core Application
So, what does endpoint control look like at the application level? Let’s return to the ASP.NET Core side of things and take a closer look at the default project structure. If you recall, the Program.cs in the template project contains a specific line worth revisiting. This configuration plays an important role in how the application handles and secures incoming requests. Let’s take a look at that setup.
// Register the WeatherForecastAgent
builder.Services.AddTransient();
// Add AspNet token validation – ** HERE **
builder.Services.AddBotAspNetAuthentication(builder.Configuration);
// Register IStorage. For development, MemoryStorage is suitable.
// For production Agents, persisted storage should be used so
// that state survives Agent restarts, and operate correctly
// in a cluster of Agent instances.
builder.Services.AddSingleton();
As it turns out, the AddBotAspNetAuthentication method referenced earlier in Program.cs is actually defined in the same project, within a file named AspNetExtensions.cs. This method is where access token validation is implemented and enforced. Let’s take a closer look at a key portion of the AddBotAspNetAuthentication method from AspNetExtensions.cs:
public static void AddBotAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = “TokenValidation”, ILogger logger = null)
{
IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName);
List validTokenIssuers = tokenValidationSection.GetSection(“ValidIssuers”).Get<List>();
List audiences = tokenValidationSection.GetSection(“Audiences”).Get<List>();
if (!tokenValidationSection.Exists())
{
logger?.LogError(“Missing configuration section ‘{tokenValidationSectionName}’. This section is required to be present in appsettings.json”,tokenValidationSectionName);
throw new InvalidOperationException($”Missing configuration section ‘{tokenValidationSectionName}’. This section is required to be present in appsettings.json”);
}
// If ValidIssuers is empty, default for ABS Public Cloud
if (validTokenIssuers == null || validTokenIssuers.Count == 0)
{
validTokenIssuers =
[
“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”,
];
string tenantId = tokenValidationSection[“TenantId”];
if (!string.IsNullOrEmpty(tenantId))
{
validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, tenantId));
validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, tenantId));
}
}
if (audiences == null || audiences.Count == 0)
{
throw new ArgumentException($”{tokenValidationSectionName}:Audiences requires at least one value”);
}
bool isGov = tokenValidationSection.GetValue(“IsGov”, false);
bool azureBotServiceTokenHandling = tokenValidationSection.GetValue(“AzureBotServiceTokenHandling”, true);
// If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens.
string azureBotServiceOpenIdMetadataUrl = tokenValidationSection[“AzureBotServiceOpenIdMetadataUrl”];
if (string.IsNullOrEmpty(azureBotServiceOpenIdMetadataUrl))
{
azureBotServiceOpenIdMetadataUrl = isGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl;
}
// If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens.
string openIdMetadataUrl = tokenValidationSection[“OpenIdMetadataUrl”];
if (string.IsNullOrEmpty(openIdMetadataUrl))
{
openIdMetadataUrl = isGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl;
}
TimeSpan openIdRefreshInterval = tokenValidationSection.GetValue(“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 = validTokenIssuers,
ValidAudiences = audiences,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
};
// Using Microsoft.IdentityModel.Validators
options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation();
options.Events = new JwtBearerEvents
{
// Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens.
OnMessageReceived = async context =>
{
string authorizationHeader = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authorizationHeader))
{
// Default to AadTokenValidation handling
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”)
{
// Default to AadTokenValidation handling
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 (azureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer))
{
// Use the Bot Framework authority for this configuration manager
context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(azureBotServiceOpenIdMetadataUrl, key =>
{
return new ConfigurationManager(azureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient())
{
AutomaticRefreshInterval = openIdRefreshInterval
};
});
}
else
{
context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(openIdMetadataUrl, key =>
{
return new ConfigurationManager(openIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient())
{
AutomaticRefreshInterval = openIdRefreshInterval
};
});
}
await Task.CompletedTask.ConfigureAwait(false);
},
OnTokenValidated = context =>
{
logger?.LogDebug(“TOKEN Validated”);
return Task.CompletedTask;
},
OnForbidden = context =>
{
logger?.LogWarning(“Forbidden: {m}”, context.Result.ToString());
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
logger?.LogWarning(“Auth Failed {m}”, context.Exception.ToString());
return Task.CompletedTask;
}
};
});
}
From examining the code, we can see that it reads configuration settings from the appsettings.{your-env}.json file and uses them during token validation. In particular, the following line stands out:
TokenValidationParameters.ValidAudiences = audiences;
This ensures that only tokens issued for the configured audience (i.e., your Azure Bot Service’s Service Principal) will be accepted. Any requests carrying tokens with mismatched audiences will be rejected during validation.
One critical observation is that if no access token is provided at all, the code effectively lets the request through without enforcing validation. This means that if the Service Principal is misconfigured or lacks proper permissions, and therefore no token is issued with the request, the bot may still continue processing it without rejecting the request. This could potentially create a security loophole, especially if the backend API is publicly accessible.
OnMessageReceived = async context =>
{
string authorizationHeader = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authorizationHeader))
{
// Default to AadTokenValidation handling
context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager;
await Task.CompletedTask.ConfigureAwait(false);
return;
}
Additional Security Concerns and Improvements
Another point worth noting about the current code is that the Custom Engine Agent app can be copied and uploaded to a different Entra ID tenant, and it would still work. (Admittedly, this might be intentional since the architecture assumes providing Custom Engine Agent services to multiple organizations.)
The project template and Teams settings raise two key security concerns that we should address:
- Reject requests when the token is missing – token should not be empty.
- Block access from unknown or unauthorized Entra ID tenants.
To enforce the above, you will need to update the Service Principal configuration accordingly. Specifically, open the Service Principal’s API permissions tab and add the following permission:
- User.Read.All
Without this permission, access tokens will not be issued, making token validation impossible. After updating the Service Principal permissions, run your ASP.NET Core app and set a breakpoint around the following code to inspect the contents of the token included in the Authorization header. This will help you verify whether the token is correctly issued and contains the expected claims.
string authorizationHeader = context.Request.Headers.Authorization.ToString();
The token is Base64 encoded, so let’s decode it to inspect its contents. I asked Copilot to help us decode the token so we can better understand the claims and data included inside.
Let’s inspect the token contents. After decoding the token (some parts are redacted for privacy), we can see that:
- The aud (audience) claim contains the Service Principal’s client ID.
- The serviceurl claim includes the Entra ID tenant ID.
I attempted to configure the authorization settings to include the Tenant ID directly in the access token claims, but was not successful this time. Below is a sample code snippet that implements of the following requirements:
- Reject requests with an empty or missing token.
- Deny access from unknown Entra ID tenants.
This is the sample code for “1. Reject requests with an empty or missing token“. I’ve added comments in the code to clearly indicate what was changed.
public static void AddBotAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = “TokenValidation”, ILogger logger = null)
{
IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName);
List validTokenIssuers = tokenValidationSection.GetSection(“ValidIssuers”).Get<List>();
List audiences = tokenValidationSection.GetSection(“Audiences”).Get<List>();
if (!tokenValidationSection.Exists())
{
logger?.LogError(“Missing configuration section ‘{tokenValidationSectionName}’. This section is required to be present in appsettings.json”,tokenValidationSectionName);
throw new InvalidOperationException($”Missing configuration section ‘{tokenValidationSectionName}’. This section is required to be present in appsettings.json”);
}
// If ValidIssuers is empty, default for ABS Public Cloud
if (validTokenIssuers == null || validTokenIssuers.Count == 0)
{
validTokenIssuers =
[
“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”,
];
string tenantId = tokenValidationSection[“TenantId”];
if (!string.IsNullOrEmpty(tenantId))
{
validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, tenantId));
validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, tenantId));
}
}
if (audiences == null || audiences.Count == 0)
{
throw new ArgumentException($”{tokenValidationSectionName}:Audiences requires at least one value”);
}
bool isGov = tokenValidationSection.GetValue(“IsGov”, false);
bool azureBotServiceTokenHandling = tokenValidationSection.GetValue(“AzureBotServiceTokenHandling”, true);
// If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens.
string azureBotServiceOpenIdMetadataUrl = tokenValidationSection[“AzureBotServiceOpenIdMetadataUrl”];
if (string.IsNullOrEmpty(azureBotServiceOpenIdMetadataUrl))
{
azureBotServiceOpenIdMetadataUrl = isGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl;
}
// If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens.
string openIdMetadataUrl = tokenValidationSection[“OpenIdMetadataUrl”];
if (string.IsNullOrEmpty(openIdMetadataUrl))
{
openIdMetadataUrl = isGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl;
}
TimeSpan openIdRefreshInterval = tokenValidationSection.GetValue(“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, // this option enables to validate the audience claim with audiences values
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5),
ValidIssuers = validTokenIssuers,
ValidAudiences = audiences,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
};
// Using Microsoft.IdentityModel.Validators
options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation();
options.Events = new JwtBearerEvents
{
// Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens.
OnMessageReceived = async context =>
{
string authorizationHeader = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authorizationHeader))
{
// Default to AadTokenValidation handling
// context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager;
// await Task.CompletedTask.ConfigureAwait(false);
// return;
//
// Fail the request when the token is empty
context.Fail(“Authorization header is missing.”);
logger?.LogWarning(“Authorization header is missing.”);
return;
}
string[] parts = authorizationHeader?.Split(‘ ‘);
if (parts.Length != 2 || parts[0] != “Bearer”)
{
// Default to AadTokenValidation handling
context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager;
await Task.CompletedTask.ConfigureAwait(false);
return;
}
Next, we should implement about “2. Deny access from unknown Entra ID tenants” We can retrieve the Tenant ID inside the MessageActivityAsync method of Bot/WeatherAgentBot.cs. Let’s extend the logic by referring to the following sample code to capture and utilize the Tenant ID within that method.
Here is how you can extend the logic to retrieve and use the Tenant ID within the MessageActivityAsync method:
using MyM365Agent1.Bot.Agents;
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Core.Models;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace MyM365Agent1.Bot;
public class WeatherAgentBot : AgentApplication
{
private WeatherForecastAgent _weatherAgent;
private Kernel _kernel;
private readonly string _tenantId;
private readonly ILogger _logger;
public WeatherAgentBot(AgentApplicationOptions options, Kernel kernel, IConfiguration configuration, ILogger logger) : base(options)
{
_kernel = kernel ?? throw new ArgumentNullException(nameof(kernel));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync);
OnActivity(ActivityTypes.Message, MessageActivityAsync, rank: RouteRank.Last);
// Get TenantId from TokenValidation section
var tokenValidationSection = configuration.GetSection(“TokenValidation”);
_tenantId = tokenValidationSection[“TenantId”];
}
protected async Task MessageActivityAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
// add validation of tenant ID
var activity = turnContext.Activity;
// Log: Received activity
_logger.LogInformation(“Received message activity from: {FromId}, AadObjectId:{AadObjectId}, TenantId: {TenantId}, ChannelId: {ChannelId}, ConversationType: {ConversationType}”,
activity?.From?.Id, activity?.From?.AadObjectId, activity?.Conversation?.TenantId, activity?.ChannelId, activity?.Conversation?.ConversationType);
if (activity.ChannelId != “msteams” // Ignore messages not from Teams
|| activity.Conversation?.ConversationType?.ToLowerInvariant() != “personal” // Ignore messages from team channels or group chats
|| string.IsNullOrEmpty(activity.From?.AadObjectId) // Ignore if not an AAD user (e.g., bots, guest users)
|| (!string.IsNullOrEmpty(_tenantId)
&& !string.Equals(activity.Conversation?.TenantId, _tenantId, StringComparison.OrdinalIgnoreCase))) // Ignore if tenant ID does not match
{
_logger.LogWarning(“Unauthorized serviceUrl detected: {ServiceUrl}. Expected to contain TenantId: {TenantId}”,
activity?.ServiceUrl, _tenantId);
await turnContext.SendActivityAsync(“Unauthorized service URL.”, cancellationToken: cancellationToken);
return;
}
// Setup local service connection
ServiceCollection serviceCollection = [
new ServiceDescriptor(typeof(ITurnState), turnState),
new ServiceDescriptor(typeof(ITurnContext), turnContext),
new ServiceDescriptor(typeof(Kernel), _kernel),
];
// Start a Streaming Process
await turnContext.StreamingResponse.QueueInformativeUpdateAsync(“Working on a response for you”);
ChatHistory chatHistory = turnState.GetValue(“conversation.chatHistory”, () => new ChatHistory());
_weatherAgent = new WeatherForecastAgent(_kernel, serviceCollection.BuildServiceProvider());
// Invoke the WeatherForecastAgent to process the message
WeatherForecastAgentResponse forecastResponse = await _weatherAgent.InvokeAgentAsync(turnContext.Activity.Text, chatHistory);
if (forecastResponse == null)
{
turnContext.StreamingResponse.QueueTextChunk(“Sorry, I couldn’t get the weather forecast at the moment.”);
await turnContext.StreamingResponse.EndStreamAsync(cancellationToken);
return;
}
// Create a response message based on the response content type from the WeatherForecastAgent
// Send the response message back to the user.
switch (forecastResponse.ContentType)
{
case WeatherForecastAgentResponseContentType.Text:
turnContext.StreamingResponse.QueueTextChunk(forecastResponse.Content);
break;
case WeatherForecastAgentResponseContentType.AdaptiveCard:
turnContext.StreamingResponse.FinalMessage = MessageFactory.Attachment(new Attachment()
{
ContentType = “application/vnd.microsoft.card.adaptive”,
Content = forecastResponse.Content,
});
break;
default:
break;
}
await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); // End the streaming response
}
protected async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
foreach (ChannelAccount member in turnContext.Activity.MembersAdded)
{
if (member.Id != turnContext.Activity.Recipient.Id)
{
await turnContext.SendActivityAsync(MessageFactory.Text(“Hello and Welcome! I’m here to help with all your weather forecast needs!”), cancellationToken);
}
}
}
}
Since we’re at it, I’ve added various validations as well. I hope this will be helpful as a reference for everyone.