Migrate a .NET v3 bot to a .NET Core v4 bot
APPLIES TO: SDK v4
In this article we'll convert the v3 ContosoHelpdeskChatBot into a v4 bot in a new .NET Core project. This conversion is broken down into these steps:
- Create the new project using a template.
- Install additional NuGet packages as necessary.
- Personalize your bot, update your Startup.cs file, and update your controller class.
- Update your bot class.
- Copy over and update your dialogs and models.
- Final porting step.
The result of this conversion is the .NET Core v4 ContosoHelpdeskChatBot. To migrate to a .NET Framework v4 bot without converting the project type, see Migrate a .NET v3 bot to a .NET Framework v4 bot.
Bot Framework SDK v4 is based on the same underlying REST API as SDK v3. However, SDK v4 is a refactoring of the previous version of the SDK to allow developers more flexibility and control over their bots. Major changes in the SDK include:
- State is managed via state management objects and property accessors.
- Setting up turn handler and passing activities to it has changed.
- Scorables no longer exist. You can check for "global" commands in the turn handler, before passing control to your dialogs.
- A new Dialogs library that is very different from the one in the previous version. You'll need to convert old dialogs to the new dialog system using component and waterfall dialogs and the community implementation of Formflow dialogs for v4.
For more information about specific changes, see differences between the v3 and v4 .NET SDK.
Create the new project using a template
Note
The VSIX package includes both .NET Core 2.1 and .NET Core 3.1 versions of the C# templates. When creating new bots in Visual Studio 2019 or later, you should use the .NET Core 3.1 templates. The current bot samples use .NET Core 3.1 templates. You can find the samples that use .NET Core 2.1 templates in the 4.7-archive branch of the BotBuilder-Samples repository.
To install the templates in Visual Studio, in the top menu bar, navigate to Extensions > Manage Extensions. Then search for and install Bot Framework v4 SDK for Visual Studio.
For information about deploying .NET Core 3.1 bots to Azure, see how to deploy your bot to Azure.
- If you haven't done so already, install the Bot Framework SDK v4 template for C#.
- Open Visual Studio, and create a new Echo Bot project from the template. Name your project
ContosoHelpdeskChatBot.
Install additional NuGet packages
The template installs most of the packages you will need, including the Microsoft.Bot.Builder and Microsoft.Bot.Connector packages.
Add Bot.Builder.Community.Dialogs.Formflow
This is a community library for building v4 dialogs from v3 Formflow definition files. It has Microsoft.Bot.Builder.Dialogs as one of its dependencies, so this is also installed for us.
Add log4net to support logging.
Personalize your bot
- Rename your bot file from Bots\EchoBot.cs to Bots\DialogBot.cs and rename the
EchoBotclass toDialogBot. - Rename your controller from Controllers\BotController.cs to Controllers\MessagesController.cs and rename the
BotControllerclass toMessagesController.
Update your Startup.cs file
The way state and how the bot receives incoming activities has changed. We have to set up parts of the state management infrastructure ourselves in v4. For instance, v4 uses a bot adapter to handle authentication and forward activities to your bot code, and we have declare our state properties up front.
We'll create a state property for DialogState, which we now need for dialog support in v4. We'll use dependency injection to get necessary information to the controller and bot code.
In Startup.cs:
Update the
usingstatements:using ContosoHelpdeskChatBot.Bots; using ContosoHelpdeskChatBot.Dialogs; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.BotFramework; using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Bot.Connector.Authentication; using Microsoft.Extensions.DependencyInjection;Remove this constructor:
public Startup(IConfiguration configuration) { Configuration = configuration; }Remove the
Configurationproperty.Update the
ConfigureServicesmethod with this code:// This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); // Create the credential provider to be used with the Bot Framework Adapter. services.AddSingleton<ICredentialProvider, ConfigurationCredentialProvider>(); // Create the Bot Framework Adapter with error handling enabled. services.AddSingleton<IBotFrameworkHttpAdapter, AdapterWithErrorHandler>(); // Create the storage we'll be using for User and Conversation state. (Memory is great for testing purposes.) services.AddSingleton<IStorage, MemoryStorage>(); // Create the Conversation state. (Used by the Dialog system itself.) services.AddSingleton<ConversationState>(); // Create the Root Dialog as a singleton services.AddSingleton<RootDialog>(); // Create the bot as a transient. In this case the ASP Controller is expecting an IBot. services.AddTransient<IBot, DialogBot<RootDialog>>(); }
You are going to have compile time errors at this time. We'll fix them in the next steps.
MessagesController class
This class handles a request. Dependency Injection will provide the Adapter and IBot implementation at runtime. This template class is unchanged.
This is where the bot starts a turn in v4, this is quite different form the v3 message controller. Except for the bot's turn handler itself, most of this can be thought of as boilerplate.
The bot turn handler will be defined in the Bots\DialogBot.cs.
Ignore the CancelScorable and GlobalMessageHandlersBotModule classes
Since scorables don't exist in v4 we just ignore these classes. We'll update the turn handler to react to a cancel message.
Update your bot class
In v4, the turn handler or message loop logic is primarily in a bot file. We're deriving from ActivityHandler, which defines handlers for common types of activities.
Update the Bots\DialogBots.cs file.
Update the
usingstatements:using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Schema; using System.Threading; using System.Threading.Tasks;Update
DialogBotto include a generic parameter for the dialog.public class DialogBot<T> : ActivityHandler where T : DialogAdd these fields and a constructor to initialize them. Again, ASP.NET uses dependency injection to get the parameters values.
protected readonly Dialog _dialog; protected readonly BotState _conversationState; public DialogBot(ConversationState conversationState, T dialog) { _conversationState = conversationState; _dialog = dialog; }Update
OnMessageActivityAsyncimplementation to invoke our main dialog. (We'll define theRunextension method shortly.)
protected override async Task OnMessageActivityAsync(
ITurnContext<IMessageActivity> turnContext,
CancellationToken cancellationToken)
{
// Run the Dialog with the new message Activity.
await _dialog.Run(
turnContext,
_conversationState.CreateProperty<DialogState>("DialogState"),
cancellationToken);
}
Update
OnTurnAsyncto save our conversation state at the end of the turn. In v4, we have to do this explicitly to write state out to the persistence layer.ActivityHandler.OnTurnAsyncmethod calls specific activity handler methods, based on the type of activity received, so we save state after the call to the base method.public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken) { await base.OnTurnAsync(turnContext, cancellationToken); // Save any state changes that might have occured during the turn. await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); }
Create the Run extension method
We're creating an extension method to consolidate the code needed to run a bare component dialog from our bot.
Create a DialogExtensions.cs file and implement a Run extension method.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
namespace ContosoHelpdeskChatBot
{
public static class DialogExtensions
{
public static async Task Run(this Dialog dialog, ITurnContext turnContext, IStatePropertyAccessor<DialogState> accessor, CancellationToken cancellationToken = default(CancellationToken))
{
var dialogSet = new DialogSet(accessor);
dialogSet.Add(dialog);
var dialogContext = await dialogSet.CreateContextAsync(turnContext, cancellationToken);
var results = await dialogContext.ContinueDialogAsync(cancellationToken);
if (results.Status == DialogTurnStatus.Empty)
{
await dialogContext.BeginDialogAsync(dialog.Id, null, cancellationToken);
}
}
}
}
Copy over and convert your dialogs
We'll make many changes to the original v3 dialogs to migrate them to the v4 SDK. Don't worry about the compiler errors for now. These will resolve once we've finished the conversion. In the interest of not modifying the original code more than necessary, there will remain some compiler warnings after we've finished the migration.
All of our dialogs will derive from ComponentDialog, instead of implementing the IDialog<object> interface of v3.
This bot has four dialogs that we need to convert:
| Dialog | Description |
|---|---|
| RootDialog | Presents options and starts the other dialogs. |
| InstallAppDialog | Handles requests to install an app on a machine. |
| LocalAdminDialog | Handles requests for local machine admin rights. |
| ResetPasswordDialog | Handles requests to reset your password. |
We won't copy over the CancelScorable class, as scorables no longer exist. You can check for global commands in the turn handler, before passing control to your dialogs.
These gather input, but do not perform any of these operations on your machine.
- Create a Dialogs folder in your project.
- Copy these files from the v3 project's dialogs directory into your new dialogs directory.
- InstallAppDialog.cs
- LocalAdminDialog.cs
- ResetPasswordDialog.cs
- RootDialog.cs
Make solution-wide dialog changes
- For the entire solution, Replace all occurrences of
IDialog<object>withComponentDialog. - For the entire solution, Replace all occurrences of
IDialogContextwithDialogContext. - For each dialog class, remove the
[Serializable]attribute.
Control flow and messaging within dialogs are no longer handled the same way, so we'll need to revise this as we convert each dialog.
| Operation | v3 code | v4 code |
|---|---|---|
| Handle the start of your dialog | Implement IDialog.StartAsync |
Make this the first step of a waterfall dialog, or implement Dialog.BeginDialogAsync |
| Handle continuation of your dialog | Call IDialogContext.Wait |
Add additional steps to a waterfall dialog, or implement Dialog.ContinueDialogAsync |
| Send a message to the user | Call IDialogContext.PostAsync |
Call ITurnContext.SendActivityAsync |
| Start a child dialog | Call IDialogContext.Call |
Call DialogContext.BeginDialogAsync |
| Signal that the current dialog has completed | Call IDialogContext.Done |
Call DialogContext.EndDialogAsync |
| Get the user's input | Use an IAwaitable<IMessageActivity> parameter |
Use a prompt from within a waterfall, or use ITurnContext.Activity |
Notes about the v4 code:
- Within dialog code, use the
DialogContext.Contextproperty to get the current turn context. - Waterfall steps have a
WaterfallStepContextparameter, which derives fromDialogContext. - All concrete dialog and prompt classes derive from the abstract
Dialogclass. - You assign an ID when you create a component dialog. Each dialog in a dialog set needs to be assigned an ID unique within that set.
Update the root dialog
In this bot, the root dialog prompts the user for a choice from a set of options, and then starts a child dialog based on that choice. This then loops for the lifetime of the conversation.
- We can set the main flow up as a waterfall dialog, which is a new concept in the v4 SDK. It will run through a fixed set of steps in order. For more information, see Implement sequential conversation flow.
- Prompting is now handled through prompt classes, which are short child dialogs that prompt for input, do some minimal processing and validation, and return a value. For more information, see gather user input using a dialog prompt.
In the Dialogs/RootDialog.cs file:
Update the
usingstatements:using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Dialogs.Choices; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks;We need to convert
HelpdeskOptionsoptions from a list of strings to a list of choices. This will be used with a choice prompt, which will accept the choice number (in the list), the choice value, or any of the choice's synonyms as valid input.private static List<Choice> HelpdeskOptions = new List<Choice>() { new Choice(InstallAppOption) { Synonyms = new List<string> { "install" } }, new Choice(ResetPasswordOption) { Synonyms = new List<string> { "password" } }, new Choice(LocalAdminOption) { Synonyms = new List<string> { "admin" } } };Add a constructor. This code does the following:
- Each instance of a dialog is assigned an ID when it is created. The dialog ID is part of the dialog set to which the dialog is being added. Recall that the bot was initialized with a dialog object in the
MessageControllerclass. EachComponentDialoghas its own internal dialog set, with its own set of dialog IDs. - It adds the other dialogs, including the choice prompt, as child dialogs. Here, we're just using the class name for each dialog ID.
- It defines a three-step waterfall dialog. We'll implement those in a moment.
- The dialog will first prompt the user to choose a task to perform.
- Then, start the child dialog associated with that choice.
- And finally, restart itself.
- Each step of the waterfall is a delegate, and we'll implement those next, taking existing code from the original dialog where we can.
- When you start a component dialog, it will start its initial dialog. By default, this is the first child dialog added to a component dialog. We're explicitly setting the
InitialDialogIdproperty, which means that the main waterfall dialog does not need to be the first one you add to the set. For instance, if you prefer to add prompts first, this would allow you to do so without causing a run-time issue.
public RootDialog() : base(nameof(RootDialog)) { InitialDialogId = nameof(WaterfallDialog); AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { PromptForOptionsAsync, ShowChildDialogAsync, ResumeAfterAsync, })); AddDialog(new InstallAppDialog()); AddDialog(new LocalAdminDialog()); AddDialog(new ResetPasswordDialog()); AddDialog(new ChoicePrompt(nameof(ChoicePrompt))); }- Each instance of a dialog is assigned an ID when it is created. The dialog ID is part of the dialog set to which the dialog is being added. Recall that the bot was initialized with a dialog object in the
We can delete the
StartAsyncmethod. When a component dialog begins, it automatically starts its initial dialog. In this case, that's the waterfall dialog we defined in the constructor. That also automatically starts at its first step.We will delete the
MessageReceivedAsyncandShowOptionsmethods, and replace them with the first step of our waterfall. These two methods greeted the user and asked them to choose one of the available options.- Here you can see the choice list and the greeting and error messages are provided as options in the call to our choice prompt.
- We don't have to specify the next method to call in the dialog, as the waterfall will continue to the next step when the choice prompt completes.
- The choice prompt will loop until it receives valid input or the whole dialog stack is canceled.
private async Task<DialogTurnResult> PromptForOptionsAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { // Prompt the user for a response using our choice prompt. return await stepContext.PromptAsync( nameof(ChoicePrompt), new PromptOptions() { Choices = HelpdeskOptions, Prompt = MessageFactory.Text(GreetMessage), RetryPrompt = MessageFactory.Text(ErrorMessage) }, cancellationToken); }We can replace
OnOptionSelectedwith the second step of our waterfall. We still start a child dialog based on the user's input.- The choice prompt returns a
FoundChoicevalue. This shows up in the step context'sResultproperty. The dialog stack treats all return values as objects. If the return value is from one of your dialogs, then you know what type of value the object is. See prompt types for a list what each prompt type returns. - Since the choice prompt won't throw an exception, we can remove the try-catch block.
- We need to add a fall through so that this method always returns an appropriate value. This code should never get hit, but if it does it will allow the dialog to "fail gracefully".
private async Task<DialogTurnResult> ShowChildDialogAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { // string optionSelected = await userReply; var optionSelected = (stepContext.Result as FoundChoice).Value; switch (optionSelected) { case InstallAppOption: //context.Call(new InstallAppDialog(), this.ResumeAfterOptionDialog); //break; return await stepContext.BeginDialogAsync( nameof(InstallAppDialog), cancellationToken); case ResetPasswordOption: //context.Call(new ResetPasswordDialog(), this.ResumeAfterOptionDialog); //break; return await stepContext.BeginDialogAsync( nameof(ResetPasswordDialog), cancellationToken); case LocalAdminOption: //context.Call(new LocalAdminDialog(), this.ResumeAfterOptionDialog); //break; return await stepContext.BeginDialogAsync( nameof(LocalAdminDialog), cancellationToken); } // We shouldn't get here, but fail gracefully if we do. await stepContext.Context.SendActivityAsync( "I don't recognize that option.", cancellationToken: cancellationToken); // Continue through to the next step without starting a child dialog. return await stepContext.NextAsync(cancellationToken: cancellationToken); }- The choice prompt returns a
Finally, replace the old
ResumeAfterOptionDialogmethod with the last step of our waterfall.- Instead of ending the dialog and returning the ticket number as we did in the original dialog, we're restarting the waterfall by replacing on the stack the original instance with a new instance of itself. We can do this, since the original app always ignored the return value (the ticket number) and restarted the root dialog.
private async Task<DialogTurnResult> ResumeAfterAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { try { //var message = await userReply; var message = stepContext.Context.Activity; var ticketNumber = new Random().Next(0, 20000); //await context.PostAsync($"Thank you for using the Helpdesk Bot. Your ticket number is {ticketNumber}."); await stepContext.Context.SendActivityAsync( $"Thank you for using the Helpdesk Bot. Your ticket number is {ticketNumber}.", cancellationToken: cancellationToken); //context.Done(ticketNumber); } catch (Exception ex) { // await context.PostAsync($"Failed with message: {ex.Message}"); await stepContext.Context.SendActivityAsync( $"Failed with message: {ex.Message}", cancellationToken: cancellationToken); // In general resume from task after calling a child dialog is a good place to handle exceptions // try catch will capture exceptions from the bot framework awaitable object which is essentially "userReply" logger.Error(ex); } // Replace on the stack the current instance of the waterfall with a new instance, // and start from the top. return await stepContext.ReplaceDialogAsync( nameof(WaterfallDialog), cancellationToken: cancellationToken); }
Update the install app dialog
The install app dialog performs a few logical tasks, which we'll set up as a 4-step waterfall dialog. How you factor existing code into waterfall steps is a logical exercise for each dialog. For each step, the original method the code came from is noted.
- Asks the user for a search string.
- Queries a database for potential matches.
- If there is one hit, select this and continue.
- If there are multiple hits, it asks the user to choose one.
- If there are no hits, the dialog exits.
- Asks the user for a machine to install the app on.
- Writes the information to a database and sends a confirmation message.
In the Dialogs/InstallAppDialog.cs file:
Update the
usingstatements:using ContosoHelpdeskChatBot.Models; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Dialogs.Choices; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks;Define a constant for the key we'll use to track collected information.
// Set up keys for managing collected information. private const string InstallInfo = "installInfo";Add a constructor and initialize the component's dialog set.
public InstallAppDialog() : base(nameof(InstallAppDialog)) { // Initialize our dialogs and prompts. InitialDialogId = nameof(WaterfallDialog); AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { GetSearchTermAsync, ResolveAppNameAsync, GetMachineNameAsync, SubmitRequestAsync, })); AddDialog(new TextPrompt(nameof(TextPrompt))); AddDialog(new ChoicePrompt(nameof(ChoicePrompt))); }We can replace
StartAsyncwith the first step of our waterfall.- We have to manage state ourselves, so we'll track the install app object in dialog state.
- The message asking the user for input becomes an option in the call to the prompt.
private async Task<DialogTurnResult> GetSearchTermAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { // Create an object in dialog state in which to track our collected information. stepContext.Values[InstallInfo] = new InstallApp(); // Ask for the search term. return await stepContext.PromptAsync( nameof(TextPrompt), new PromptOptions { Prompt = MessageFactory.Text("Ok let's get started. What is the name of the application? "), }, cancellationToken); }We can replace
appNameAsyncandmultipleAppsAsyncwith the second step of our waterfall.- We're getting the prompt result now, instead of just looking at the user's last message.
- The database query and if statements are organized the same as in
appNameAsync. The code in each block of the if statement has been updated to work with v4 dialogs.- If we have one hit, we'll update dialog state and continue with the next step.
- If we have multiple hits, we'll use our choice prompt to ask the user to choose from the list of options. This means we can just delete
multipleAppsAsync. - If we have no hits, we'll end this dialog and return null to the root dialog.
private async Task<DialogTurnResult> ResolveAppNameAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { // Get the result from the text prompt. var appname = stepContext.Result as string; // Query the database for matches. var names = await this.GetAppsAsync(appname); if (names.Count == 1) { // Get our tracking information from dialog state and add the app name. var install = stepContext.Values[InstallInfo] as InstallApp; install.AppName = names.First(); return await stepContext.NextAsync(); } else if (names.Count > 1) { // Ask the user to choose from the list of matches. return await stepContext.PromptAsync( nameof(ChoicePrompt), new PromptOptions { Prompt = MessageFactory.Text("I found the following applications. Please choose one:"), Choices = ChoiceFactory.ToChoices(names), }, cancellationToken); } else { // If no matches, exit this dialog. await stepContext.Context.SendActivityAsync( $"Sorry, I did not find any application with the name '{appname}'.", cancellationToken: cancellationToken); return await stepContext.EndDialogAsync(null, cancellationToken); } }appNameAsyncalso asked the user for their machine name after it resolved the query. We'll capture that portion of the logic in the next step of the waterfall.- Again, in v4 we have to manage state ourselves. The only tricky thing here is that we can get to this step through two different logic branches in the previous step.
- We'll ask the user for a machine name using the same text prompt as before, just supplying different options this time.
private async Task<DialogTurnResult> GetMachineNameAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { // Get the tracking info. If we don't already have an app name, // Then we used the choice prompt to get it in the previous step. var install = stepContext.Values[InstallInfo] as InstallApp; if (install.AppName is null) { install.AppName = (stepContext.Result as FoundChoice).Value; } // We now need the machine name, so prompt for it. return await stepContext.PromptAsync( nameof(TextPrompt), new PromptOptions { Prompt = MessageFactory.Text( $"Found {install.AppName}. What is the name of the machine to install application?"), }, cancellationToken); }The logic from
machineNameAsyncis wrapped up in the final step of our waterfall.- We retrieve the machine name from the text prompt result and update dialog state.
- We are removing the call to update the database, as the supporting code is in a different project.
- Then we're sending the success message to the user and ending the dialog.
private async Task<DialogTurnResult> SubmitRequestAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { var install = default(InstallApp); if (stepContext.Reason != DialogReason.CancelCalled) { // Get the tracking info and add the machine name. install = stepContext.Values[InstallInfo] as InstallApp; install.MachineName = stepContext.Context.Activity.Text; //TODO: Save to this information to the database. } await stepContext.Context.SendActivityAsync( $"Great, your request to install {install.AppName} on {install.MachineName} has been scheduled.", cancellationToken: cancellationToken); return await stepContext.EndDialogAsync(null, cancellationToken); }To simulate the database call, we mock up
getAppsAsyncto query a static list, instead of the database.private async Task<List<string>> GetAppsAsync(string Name) { var names = new List<string>(); // Simulate querying the database for applications that match. return (from app in AppMsis where app.ToLower().Contains(Name.ToLower()) select app).ToList(); } // Example list of app names in the database. private static readonly List<string> AppMsis = new List<string> { "µTorrent 3.5.0.44178", "7-Zip 17.1", "Ad-Aware 9.0", "Adobe AIR 2.5.1.17730", "Adobe Flash Player (IE) 28.0.0.105", "Adobe Flash Player (Non-IE) 27.0.0.130", "Adobe Reader 11.0.14", "Adobe Shockwave Player 12.3.1.201", "Advanced SystemCare Personal 11.0.3", "Auslogics Disk Defrag 3.6", "avast! 4 Home Edition 4.8.1351", "AVG Anti-Virus Free Edition 9.0.0.698", "Bonjour 3.1.0.1", "CCleaner 5.24.5839", "Chmod Calculator 20132.4", "CyberLink PowerDVD 17.0.2101.62", "DAEMON Tools Lite 4.46.1.328", "FileZilla Client 3.5", "Firefox 57.0", "Foxit Reader 4.1.1.805", "Google Chrome 66.143.49260", "Google Earth 7.3.0.3832", "Google Toolbar (IE) 7.5.8231.2252", "GSpot 2701.0", "Internet Explorer 903235.0", "iTunes 12.7.0.166", "Java Runtime Environment 6 Update 17", "K-Lite Codec Pack 12.1", "Malwarebytes Anti-Malware 2.2.1.1043", "Media Player Classic 6.4.9.0", "Microsoft Silverlight 5.1.50907", "Mozilla Thunderbird 57.0", "Nero Burning ROM 19.1.1005", "OpenOffice.org 3.1.1 Build 9420", "Opera 12.18.1873", "Paint.NET 4.0.19", "Picasa 3.9.141.259", "QuickTime 7.79.80.95", "RealPlayer SP 12.0.0.319", "Revo Uninstaller 1.95", "Skype 7.40.151", "Spybot - Search & Destroy 1.6.2.46", "SpywareBlaster 4.6", "TuneUp Utilities 2009 14.0.1000.353", "Unlocker 1.9.2", "VLC media player 1.1.6", "Winamp 5.56 Build 2512", "Windows Live Messenger 2009 16.4.3528.331", "WinPatrol 2010 31.0.2014", "WinRAR 5.0", };
Update the local admin dialog
In v3, this dialog greeted the user, started the Formflow dialog, and then saved the result off to a database. This translates easily into a two-step waterfall.
Update the
usingstatements. Note that this dialog includes a v3 Formflow dialog. In v4 we can use the community Formflow library.using Bot.Builder.Community.Dialogs.FormFlow; using ContosoHelpdeskChatBot.Models; using Microsoft.Bot.Builder.Dialogs; using System.Threading; using System.Threading.Tasks;We can remove the instance property for
LocalAdmin, as the result will be available in dialog state.Add a constructor and initialize the component's dialog set. The Formflow dialog is created in the same way. We're just adding it to the dialog set of our component in the constructor.
public LocalAdminDialog() : base(nameof(LocalAdminDialog)) { InitialDialogId = nameof(WaterfallDialog); AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { BeginFormflowAsync, SaveResultAsync, })); AddDialog(FormDialog.FromForm(BuildLocalAdminForm, FormOptions.PromptInStart)); }We can replace
StartAsyncwith the first step of our waterfall. We already created the Formflow in the constructor, and the other two statements translate to this. Note thatFormBuilderassigns the model's type name as the ID of the generated dialog, which isLocalAdminPromptfor this model.private async Task<DialogTurnResult> BeginFormflowAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { await stepContext.Context.SendActivityAsync("Great I will help you request local machine admin."); // Begin the Formflow dialog. return await stepContext.BeginDialogAsync( nameof(LocalAdminPrompt), cancellationToken: cancellationToken); }We can replace
ResumeAfterLocalAdminFormDialogwith the second step of our waterfall. We have to get the return value from the step context, instead of from an instance property.private async Task<DialogTurnResult> SaveResultAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { // Get the result from the Formflow dialog when it ends. if (stepContext.Reason != DialogReason.CancelCalled) { var admin = stepContext.Result as LocalAdminPrompt; //TODO: Save to this information to the database. } return await stepContext.EndDialogAsync(null, cancellationToken); }BuildLocalAdminFormremains largely the same, except we don't have the Formflow update the instance property.// Nearly the same as before. private IForm<LocalAdminPrompt> BuildLocalAdminForm() { // Here's an example of how validation can be used with FormBuilder. return new FormBuilder<LocalAdminPrompt>() .Field(nameof(LocalAdminPrompt.MachineName), validate: async (state, value) => { var result = new ValidateResult { IsValid = true, Value = value }; //add validation here //this.admin.MachineName = (string)value; return result; }) .Field(nameof(LocalAdminPrompt.AdminDuration), validate: async (state, value) => { var result = new ValidateResult { IsValid = true, Value = value }; //add validation here //this.admin.AdminDuration = Convert.ToInt32((long)value) as int?; return result; }) .Build(); }
Update the reset password dialog
In v3, this dialog greeted the user, authorized the user with a pass code, failed out or started the Formflow dialog, and then reset the password. This still translates well into a waterfall.
Update the
usingstatements. Note that this dialog includes a v3 Formflow dialog. In v4 we can use the community Formflow library.using Bot.Builder.Community.Dialogs.FormFlow; using ContosoHelpdeskChatBot.Models; using Microsoft.Bot.Builder.Dialogs; using System; using System.Threading; using System.Threading.Tasks;Add a constructor and initialize the component's dialog set. The Formflow dialog is created in the same way. We're just adding it to the dialog set of our component in the constructor.
public ResetPasswordDialog() : base(nameof(ResetPasswordDialog)) { InitialDialogId = nameof(WaterfallDialog); AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { BeginFormflowAsync, ProcessRequestAsync, })); AddDialog(FormDialog.FromForm(BuildResetPasswordForm, FormOptions.PromptInStart)); }We can replace
StartAsyncwith the first step of our waterfall. We already created the Formflow in the constructor. Otherwise, we're keeping the same logic, just translating the v3 calls to their v4 equivalents.private async Task<DialogTurnResult> BeginFormflowAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { await stepContext.Context.SendActivityAsync("Alright I will help you create a temp password."); // Check the passcode and fail out or begin the Formflow dialog. if (SendPassCode(stepContext)) { return await stepContext.BeginDialogAsync( nameof(ResetPasswordPrompt), cancellationToken: cancellationToken); } else { //here we can simply fail the current dialog because we have root dialog handling all exceptions throw new Exception("Failed to send SMS. Make sure email & phone number has been added to database."); } }sendPassCodeis left mainly as an exercise. The original code is commented out, and the method just returns true. Also, we can remove the email address again, as it wasn't used in the original bot.private bool SendPassCode(DialogContext context) { //bool result = false; //Recipient Id varies depending on channel //refer ChannelAccount class https://docs.botframework.com/en-us/csharp/builder/sdkreference/dd/def/class_microsoft_1_1_bot_1_1_connector_1_1_channel_account.html#a0b89cf01fdd73cbc00a524dce9e2ad1a //as well as Activity class https://docs.botframework.com/en-us/csharp/builder/sdkreference/dc/d2f/class_microsoft_1_1_bot_1_1_connector_1_1_activity.html //int passcode = new Random().Next(1000, 9999); //Int64? smsNumber = 0; //string smsMessage = "Your Contoso Pass Code is "; //string countryDialPrefix = "+1"; // TODO: save PassCode to database //using (var db = new ContosoHelpdeskContext()) //{ // var reset = db.ResetPasswords.Where(r => r.EmailAddress == email).ToList(); // if (reset.Count >= 1) // { // reset.First().PassCode = passcode; // smsNumber = reset.First().MobileNumber; // result = true; // } // db.SaveChanges(); //} // TODO: send passcode to user via SMS. //if (result) //{ // result = Helper.SendSms($"{countryDialPrefix}{smsNumber.ToString()}", $"{smsMessage} {passcode}"); //} //return result; return true; }BuildResetPasswordFormhas no changes.We can replace
ResumeAfterResetPasswordFormDialogwith the second step of our waterfall, and we'll get the return value from the step context. We've removed the email address that the original dialog didn't do anything with, and we've provided a dummy result instead of querying the database. We're keeping the same logic, just translating the v3 calls to their v4 equivalents.private async Task<DialogTurnResult> ProcessRequestAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken = default(CancellationToken)) { // Get the result from the Formflow dialog when it ends. if (stepContext.Reason != DialogReason.CancelCalled) { var prompt = stepContext.Result as ResetPasswordPrompt; int? passcode; // TODO: Retrieve the passcode from the database. passcode = 1111; if (prompt.PassCode == passcode) { string temppwd = "TempPwd" + new Random().Next(0, 5000); await stepContext.Context.SendActivityAsync( $"Your temp password is {temppwd}", cancellationToken: cancellationToken); } } return await stepContext.EndDialogAsync(null, cancellationToken); }
Copy over and update models as necessary
You can use the same v3 models with the v4 community form flow library.
- Create a Models folder in your project.
- Copy these files from the v3 project's models directory into your new models directory.
- InstallApp.cs
- LocalAdmin.cs
- LocalAdminPrompt.cs
- ResetPassword.cs
- ResetPasswordPrompt.cs
Update using statements
We need to update using statements in the model classes as shown next.
In InstallApps.cs change them to this:
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema;In LocalAdmin.cs change them to this:
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema;In LocalAdminPrompt.cs change them to this:
using Bot.Builder.Community.Dialogs.FormFlow;In ResetPassword.cs change them to this:
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema;Also, delete the
usingstatements inside the namespace.In ResetPasswordPrompt.cs change them to this:
using Bot.Builder.Community.Dialogs.FormFlow; using System;
Additional changes
In ResetPassword.cs change the return type of the MobileNumber as follows:
public long? MobileNumber { get; set; }
Final porting steps
To complete the porting process, perform these steps:
Create an
AdapterWithErrorHandlerclass to define an adapter which includes an error handler that can catch exceptions in the middleware or application. The adapter processes and directs incoming activities in through the bot middleware pipeline to your bot's logic and then back out again. Use the following code to create the class:using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Bot.Connector.Authentication; using System; namespace ContosoHelpdeskChatBot { public class AdapterWithErrorHandler : BotFrameworkHttpAdapter { private static log4net.ILog logger = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType); public AdapterWithErrorHandler( ICredentialProvider credentialProvider, ConversationState conversationState = null) : base(credentialProvider) { OnTurnError = async (turnContext, exception) => { // Log any leaked exception from the application. logger.Error($"Exception caught : {exception.Message}"); // Send a catch-all apology to the user. await turnContext.SendActivityAsync("Sorry, it looks like something went wrong."); if (conversationState != null) { try { // Delete the conversationState for the current conversation to prevent the // bot from getting stuck in a error-loop caused by being in a bad state. // ConversationState should be thought of as similar to "cookie-state" in a Web pages. await conversationState.DeleteAsync(turnContext); } catch (Exception e) { logger.Error($"Exception caught on attempting to Delete ConversationState : {e.Message}"); } } }; } } }Modify the wwwroot\default.htm page as you see fit.
Run and test your bot in the Emulator
At this point, we should be able to run the bot locally in IIS and attach to it with the Emulator.
- Run the bot in IIS.
- Start the Emulator and connect to the bot's endpoint (for example,
http://localhost:3978/api/messages).- If this is the first time you are running the bot then click File > New Bot and follow the instructions on screen. Otherwise, click File > Open Bot to open an existing bot.
- Double check your port settings in the configuration. For example, if the bot opened in your browser to
http://localhost:3979/, then in the Emulator, set the bot's endpoint tohttp://localhost:3979/api/messages.
- All four dialogs should work, and you can set breakpoints in the waterfall steps to check what the dialog context and dialog state is at these points.
Additional resources
v4 conceptual topics:
v4 how-to topics: