Guiding the conversation with the Bot framework and FormFlow

FormFlow creates and manages a “guided conversation”. It can be used to gain input from a user in a menu driven kind of way.

Note: the example on the Basic features of FormFlow page covers the basic features really well. In my post I’ll just try to break down these steps and hopefully add some useful hints/tips.

Let’s get started

Let’s implement something from scratch to gain an idea of the process of creating a form flow. Like the example supplied by Microsoft, we’ll begin by creating a set of options as enumerations and get FormFlow to create the conversation for us. We’re going to create a really simple PC building service.

First off we’re going to create the PcBuilder class and hook it into the “conversation”. Here’s the builder

[Serializable]
public class PcBuilder
{
   public static IForm<PcBuilder> BuildForm()
   {
      return new FormBuilder<PcBuilder>()
         .Message("Welcome to PC Builder")
         .Build();
   }
}

Now in the MessageController.cs we want the ActivityType.Message to be handled like this

if (activity.Type == ActivityTypes.Message)
{
   await Conversation.SendAsync(
      activity, 
      () => Chain.From(
         () => FormDialog.FromForm(PcBuilder.BuildForm)
      )
   );
}

When a message comes in to initiate a conversation (i.e. just type some text into the Bot emulator and press enter to initiate a message) the FormDialog will take control of our conversation using the PcBuilder to create an menu driven entry form.

Note: Running this code “as is” will result in a very short conversation. No options will be displayed and nothing will be captured.

In it’s basic form we can use enumerations and fields to capture information, so for example our first question to a user wanting to build a PC is “what processor do you want?”. In it’s basic form we could simply declare an enum such as

public enum Processor
{
   IntelCoreI3,
   IntelCoreI7,
   ArmRyzen3
}

and we need to not only capture this but tell FormFlow to use it. All we need to do is add a field to the PcBuilder class such as

public class PcBuilder
{
   public Processor? Processor;

   // other code
}

Now if we initiate a conversation, FormFlow takes a real good stab at displaying the processor options in a human readable way. On my emulator this displays

Welcome to PC Builder

Please select a processor
Intel Core I 3
Intel Core I 7
Arm Ryzen 3

and now FormFlow waits for me to choose an option. Once chosen (I chose Intel Core I 7) it’ll display

Is this your selection?
   Processor: Intel Core I 7

to which the response expected is Y, Yes, N or No (cases insensitive). A “no” will result in the menu being displayed again and the user can being choosing options from scratch.

The first problem I can see is that, whilst it takes a good stab at converting the enum’s into human readable strings, we know that usually Intel Core I 7 would be written as Intel Core i7 so it’d be good if we had something like the component DescriptionAttribute to apply to the enum for FormFlow to read.

Luckily they’ve thought of this already with the DescribeAttribute which allows us to override the description text, however if you change the code to

public enum Processor
{
   [Describe("Intel Core i3")]
   IntelCoreI3,
   [Describe("Intel Core i7")]
   IntelCoreI7,
   [Describe("ARM Ryzen 3")]
   ArmRyzen3
}

things will not quite work as hoped. Selecting either of the Intel options (even via the buttons in the emulator) will result in a By “Intel Core” processor did you mean message with the two Intel options, selecting either will result in “Intel Core i7” is not a processor option. What we need to do is now add options to the enum to override the “term” used for the selection, so our code now looks like this

public enum Processor
{
   [Describe("Intel Core i3")]
   [Terms("Intel Core i3", "i3")]
   IntelCoreI3,
   [Describe("Intel Core i7")]
   [Terms("Intel Core i7", "i7")]
   IntelCoreI7,
   [Describe("ARM Ryzen 3")]
   ArmRyzen3
}

Let’s move things along…

Next we want the user to choose from some predefined memory options, so again we’ll add an enum for this (I’m not going to bother adding Describe and Terms to these, just to reduce the code)

public enum Memory
{
   TwoGb, FourGb, EightGb, 
   SixtenGb, ThiryTwoGb, SixtyFourGb
}

To register these within the FormFlow conversation we add this to the PcBuilder like we did with the Processor. The order is important, place the field before Processor and this will be the first question asked, place after it and obviously the Processor will be asked about first. So we now have

public class PcBuilder
{
   public Processor? Processor;
   public Memory? Memory;

   // other code
}

So far we’ve look into single options, but what about if we have a bunch of “add-ons” to our PC builder, you might want to add speakers, keyboard, a mouse etc. We can simply add a new enum and then a field of type List<AddOn>. For example

public enum AddOns
{
   Speakers = 1, Mouse, Keyboard, MouseMat
}

Note: the 0 value enum is reserved for unknown values, so either use the above, where you specify the Speaker (in this example) as starting the enum at the value 1 or put an Unkown (or whatever name you want) as the 0 value.

Note: Also don’t use IList for your field or you’ll get find no options are displayed. Ofcourse this makes sense as the field is not a concrete type that can be created by FormFlow.Note: By default, a list of options will not include duplicates. Hence an input of 1, 1, 4 will result in the value Speakers and MouseMat (no second set of Speakers).

Dynamic fields

In our example we’ve allowed a pretty standard set of inputs, but what if the user chose an Intel Core i3 and now needed to choose a motherboard. It would not make sense to offer up i7 compatible or ARM compatible motherboards. So let’s look at how we might solve this. We’ll create a enum for motherboards, like this

public enum Motherboard
{
   I3Compatible1,
   I3Compatible2,
   I7Compatible,
   ArmCompatible
}

It’s been a while since I built my last computer, so I’ve no idea what the current list of possible motherboards might be. But this set of options should be self-explanatory.

Currently (I haven’t found an alternative for this) the way to achieve this is to take over the creation and handling of fields, for example BuildForm would now have the following code

return new FormBuilder<PcBuilder>()
   .Message("Welcome to PC Builder")
   .Field(new FieldReflector<PcBuilder>(nameof(Processor)))
   .Field(new FieldReflector<PcBuilder>(nameof(Motherboard))
      .SetType(typeof(Motherboard))
      .SetDefine((state, f) =>
      {
         const string i3Compat1 = "i3 Compatible 1";
         const string i3Compat2 = "i3 Compatible 2";
         const string i7Compat = "i7 Compatible";
         const string armCompat = "ARM Compatible";

         f.RemoveValues();
         if (state.Processor == Dialogs.Processor.IntelCoreI3)
         {
            f.AddDescription(Dialogs.Motherboard.I3Compatible1, i3Compat1);
            f.AddTerms(Dialogs.Motherboard.I3Compatible1, i3Compat1);
            f.AddDescription(Dialogs.Motherboard.I3Compatible2, i3Compat2);
            f.AddTerms(Dialogs.Motherboard.I3Compatible2, i3Compat2);
         }
         else if (state.Processor == Dialogs.Processor.IntelCoreI7)
         {
            f.AddDescription(Dialogs.Motherboard.I7Compatible, i7Compat);
            f.AddTerms(Dialogs.Motherboard.I7Compatible, i7Compat);
         }
         else if (state.Processor == Dialogs.Processor.ArmRyzen3)
         {
            f.AddDescription(Dialogs.Motherboard.ArmCompatible, armCompat);
            f.AddTerms(Dialogs.Motherboard.ArmCompatible, armCompat);
         }
         else
         {
            f.AddDescription(Dialogs.Motherboard.I3Compatible1, i3Compat1);
            f.AddTerms(Dialogs.Motherboard.I3Compatible1, i3Compat1);

            f.AddDescription(Dialogs.Motherboard.I3Compatible2, i3Compat2);
            f.AddTerms(Dialogs.Motherboard.I3Compatible2, i3Compat2);

            f.AddDescription(Dialogs.Motherboard.I7Compatible, i7Compat);
            f.AddTerms(Dialogs.Motherboard.I7Compatible, i7Compat);

            f.AddDescription(Dialogs.Motherboard.ArmCompatible, armCompat);
            f.AddTerms(Dialogs.Motherboard.ArmCompatible, armCompat);
         }
         return Task.FromResult(true);
   }))
   .OnCompletion(OnCompletion)
   .AddRemainingFields()
   .Build();

Notice once we start to supply the fields we’re pretty much taking control of the supply of and order of fields which the data entry takes.

Ofcourse the code to supply the descriptions/terms could be a lot nicer.

Customization

We can customize some of the default behaviour (as seen with Terms and Describe). We can also change the prompt for a field, for example

[Prompt("What {&} would you like? {||}")]
public List<AddOns> AddOns;

Now when this part of the conversation is reached the prompt will say “What add ons would you like?” and then list them. The {&} is replaced by the field description and {||} by the options.

We can also mark a field as Optional, so for example we don’t want to force a user to select an AddOn

[Optional]
public List<AddOns> AddOns;

Now a fifth option “No Preference” is added to our list. In other words the list will be null.

Other FormFlow attributes include Numeric (allowing us to specify restrictions on the values range input). Pattern Allows us to define RegEx to validate a string field and Template allows us to supply the template to use to generate prompts and prompt values.

How do we use our data

So we’ve gathered our data, but at the end of the conversation we need to actually do something with it, like place an order.

To achieve this we amend our BuildForm method and add a method to handle the data upon completion, i.e.

public static IForm<PcBuilder> BuildForm()
{
   return new FormBuilder<PcBuilder>()
      .Message("Welcome to PC Builder")
      .OnCompletion(OnCompletion)
      .Build();
}

private static Task OnCompletion(IDialogContext context, PcBuilder state)
{
   // the state argument includes the selected options.
   return Task.CompletedTask;
}