Search code examples
c#.net-corebotframework

How to combine a dialog with other commands in Bot Framework v4 in C#?


I am new to Bot Framework v4 and am trying to write a bot that has several commands. Let's say they are help, orders, and details.

help - retrieves a message containing the commands available.
orders - retrieves a card with a table of data.
details - begins a waterfall dialog retrieving that same table of data, asks for the order #, and then retrieves its details.

The issue I'm facing is that the code in OnMessageActivityAsync runs on the second turn of the dialog. So, the user sends details command and the dialog begins, retuning the list of orders. Then the user sends an order number, but OnMessageActivityAsync runs and the switch statement hits the code in the default block.

How would I solve this? I tried to figure out a way to check if a dialog is in progress, then run the _dialog.RunAsync(... method if it is.

I've looked around for answers but haven't found anything that works. I've followed Microsoft guides to make this bot, like this guide on dialogs. I also found bot samples, which includes a bot with multiple commands, and a bot with a dialog. However, I have not found an effective way to unify them.

My code looks something like so...

TroyBot.cs

public class TroyBot<T> : ActivityHandler where T : Dialog
{
    private readonly ConversationState _conversationState;
    private readonly UserState _userState;
    private readonly T _dialog;

    public TroyBot(ConversationState conversationState, UserState userState, T dialog)
    {
        _conversationState = conversationState;
        _userState = userState;
        _dialog = dialog;
    }

    protected override async Task OnMembersAddedAsync(IList<ChannelAccount> membersAdded, ITurnContext<IConversationUpdateActivity> turnContext, CancellationToken cancellationToken)
    {
        foreach (var member in membersAdded)
        {
            if (member.Id != turnContext.Activity.Recipient.Id)
            {
                await turnContext.SendActivityAsync(MessageFactory.Text(
                    "Hi! I'm Troy. I will help you track and manage orders." +
                    "\n\n" +
                    "Say **`help`** to see what I can do."
                ), cancellationToken);
            }
        }
    }

    protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
    {
        var command = turnContext.Activity.Text.Trim().ToLower();

        switch (command)
        {
            case "orders":
                var orders = await GetOrders();
                Attachment ordersAttachment = GetOrdersAttachment(orders);
                await turnContext.SendActivityAsync(MessageFactory.Attachment(ordersAttachment), cancellationToken);
                break;

            case "help":
                var helpText = GetHelpText();
                await turnContext.SendActivityAsync(MessageFactory.Text(helpText), cancellationToken);
                break;

            case "details":
                var detailOrders = await GetOrders();
                Attachment detailOrdersAttachment = GetOrdersAttachment(detailOrders);
                await turnContext.SendActivityAsync(MessageFactory.Attachment(detailOrdersAttachment), cancellationToken);
                await _dialog.RunAsync(turnContext, _conversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);;
                break;

            default:
                await turnContext.SendActivityAsync(MessageFactory.Text(
                    "Hmmm... I don't know how to help you with that.\n\n" +
                    "Try saying `help` to see what I can do."
                ), cancellationToken);
                break;
        }
    }

    public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
    {
        await base.OnTurnAsync(turnContext, cancellationToken);

        // Save any state changes that might have occurred during the turn.
        await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken);
        await _userState.SaveChangesAsync(turnContext, false, cancellationToken);
    }

    ...

}

OrderDetailDialog.cs

public class OrderDetailDialog : ComponentDialog
{
    private readonly IOrderRepo _orderRepo;

    public OrderDetailDialog(UserState userState, IOrderRepo orderRepo)
        : base(nameof(OrderDetailDialog))
    {
        _orderRepo = orderRepo;

        var waterfallSteps = new WaterfallStep[]
        {
            OrdersStep,
            ViewOrderDetailsStep
        };

        AddDialog(new WaterfallDialog(nameof(WaterfallDialog), waterfallSteps));
        AddDialog(new NumberPrompt<int>(nameof(NumberPrompt<int>), OrderNumberPromptValidatorAsync));

        // The initial child Dialog to run.
        InitialDialogId = nameof(WaterfallDialog);
    }

    private static async Task<DialogTurnResult> OrdersStep(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        return await stepContext.PromptAsync(nameof(NumberPrompt<int>), new PromptOptions
        {
            Prompt = MessageFactory.Text("Please enter the order number."),
            RetryPrompt = MessageFactory.Text("The number must be from the list.")
        }, cancellationToken);
    }

    private static async Task<DialogTurnResult> ViewOrderDetailsStep(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        stepContext.Values["orderNumber"] = ((FoundChoice)stepContext.Result).Value;

        return await stepContext.EndDialogAsync(GetOrderDetailsCard(), cancellationToken);
    }

    private async Task<bool> OrderNumberPromptValidatorAsync(PromptValidatorContext<int> promptContext, CancellationToken cancellationToken)
    {
        var orders = await _orderRepo.GetOrders();

        return promptContext.Recognized.Succeeded && orders.Select(x => x.Id).Contains(promptContext.Recognized.Value);
    }

    private static Attachment GetOrderDetailsCard()
    {
        var card = new AdaptiveCard(new AdaptiveSchemaVersion(1, 0));
        card.Body.Add(new AdaptiveTextBlock("order details card attachment"));

        Attachment attachment = new Attachment()
        {
            ContentType = "application/vnd.microsoft.card.adaptive",
            Content = card
        };
        return attachment;
    }
}

Solution

  • The problem you are experiencing is due to how you are using the OnMessageActivityAsync activity handler. And, really, the name of the handler says it all: on every message, do something. So, despite the conversation flow entering into a dialog every message sent is going to pass thru this activity handler. Regardless of where the user is in the flow, the default switch case is always going to be triggered if it doesn't match one of the other cases.

    A better setup is to make use of the dialog system which can be a simple, but not overly flexible waterfall dialog or a component dialog. In your case, you opted for the component dialog which is good as you won't have to change too much.

    For certain, you will need to update the Startup.cs file to include something like this under ConfiguredServices:

    // The Dialog that will be run by the bot.
    services.AddSingleton<OrderDetailDialog >();
    
    // Create the bot as a transient. In this case the ASP Controller is expecting an IBot.
    services.AddTransient<IBot, DialogBot<OrderDetailDialog >>();
    

    And, your 'DialogBot.cs' file (you may have named it differently) would look something like this:

    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.Bot.Builder;
    using Microsoft.Bot.Builder.Dialogs;
    using Microsoft.Bot.Schema;
    using Microsoft.Extensions.Logging;
    
    namespace Microsoft.BotBuilderSamples
    {
        public class DialogBot<T> : ActivityHandler where T : Dialog 
        {
            protected readonly Dialog Dialog;
            protected readonly BotState ConversationState;
            protected readonly BotState UserState;
            protected readonly ILogger Logger;
    
            public DialogBot(ConversationState conversationState, UserState userState, T dialog, ILogger<DialogBot<T>> logger)
            {
                ConversationState = conversationState;
                UserState = userState;
                Dialog = dialog;
                Logger = logger;
            }
    
            public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
            {
                await base.OnTurnAsync(turnContext, cancellationToken);
    
                // Save any state changes that might have occurred during the turn.
                await ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
                await UserState.SaveChangesAsync(turnContext, false, cancellationToken);
            }
    
            protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
            {
                Logger.LogInformation("Running dialog with Message Activity.");
    
                // Run the Dialog with the new message Activity.
                await Dialog.RunAsync(turnContext, ConversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
            }
        }
    }
    

    While I do know the Bot Framework SDK fairly well, I'm more JS oriented than C#. So, please refer to this sample to be sure there aren't any other necessary changes to make. For example, implementing dialog state.

    Edit:

    I forgot to address the switch statement. I'm not entirely sure how you are using the order and help case statements, but it looks like they are only sending an activity. If that is true, they can continue to live in the OnMessageActivityAsync activity handler since, at most, they only perform one action.

    For the details dialog, you can create a 'main' dialog that then filters on the activity. When 'details' is entered as text, it then begins the OrderDetailDialog. While it is more complicated, you can refer to 13.core-bot sample to get an idea on how to setup 'MainDialog.cs'. Additionally, if you wish, you could move the entire switch statement to 'MainDialog.cs' (with some modifications). And, if you really wanted to, you could move each of the actions under order and help to their own component dialogs should you want greater uniformity.