microsoft / botbuilder-dotnet

Welcome to the Bot Framework SDK for .NET repository, which is the home for the libraries and packages that enable developers to build sophisticated bot applications using .NET.
https://github.com/Microsoft/botframework
MIT License
871 stars 478 forks source link

Using Email Channel SPAM replies #1331

Closed DanteNahuel closed 5 years ago

DanteNahuel commented 5 years ago

Hi When replying to an email received through the email channel, the bot sends over 400 replies, spamming the inbox. I'm not sure why this happens although it seems like once one email is sent, the code enters a loop of replying and receiving emails and never ends. I'm using SDK 4 .Net Core, running the code over Visual Studio Locally, using ngrok to debug locally while publishing to Azure Bot Channel portal. Here's the code:


private static async Task<DialogTurnResult> CheckQuestionAsync(
        WaterfallStepContext stepContext,
        CancellationToken cancellationToken = default(CancellationToken))
        {

             if (stepContext.Context.Activity.ChannelId == "email")
            {
            await stepContext.Context.SendActivityAsync("test");

            return await stepContext.EndDialogAsync();
        }
        }

public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            if (turnContext.Activity.Type == ActivityTypes.Message )
            {

                // Establish dialog state from the conversation state.
                DialogContext dc = await _dialogs.CreateContextAsync(turnContext, cancellationToken);

                // Get the user's info.
                UserInfo userInfo = await _accessors.UserInfoAccessor.GetAsync(turnContext, () => new UserInfo(), cancellationToken);

                await _accessors.UserInfoAccessor.SetAsync(turnContext, userInfo, cancellationToken);

                // Continue any current dialog.
                DialogTurnResult dialogTurnResult = await dc.ContinueDialogAsync();

                // Process the result of any complete dialog.
                if (dialogTurnResult.Status is DialogTurnStatus.Complete)
                {
                    var i = 0;

                    }
                }

                // Every dialog step sends a response, so if no response was sent,
                // then no dialog is currently active.

                else if (!turnContext.Responded)
                {
                    var h = 0;

                        //We need to think how to handle too many attemps.
                        await dc.BeginDialogAsync(MainDialogId, null, cancellationToken);

                }

                // Save the new turn count into the conversation state.
                await _accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
                await _accessors.UserState.SaveChangesAsync(turnContext, false, cancellationToken);
            }

            else
            {
                // Commenting this to avoid "event detected" message over the chat.
               // await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
            }
        }
DanteNahuel commented 5 years ago

I keep receiving the same reply over and over again. It's been half an hour since I disabled the email channel in Azure and stopped Visual Studio + ngrok. I don't know what makes this loop but i'm not able to stop it and it's probably going to get the outlook email account blocked.

DanteNahuel commented 5 years ago

Over 1 hour later and I kept receiving emails. I checked the email account and it was actively sending the reply over and over again, even after deleting the Email channel from the Bot Channel Azure configuration panel. I had to delete the Bot Channel Registration resource in order to make it stop. I believe this a bug and a very important once since it can make your bot spam emails like Mickey mouse can spam brooms, although I'm not sure what in my code causes it.

DanteNahuel commented 5 years ago

I'm adding more of the Code to see if that helps troubleshoot the issue because i'm lost in the woods.

To reproduce: 1) Use Ngrok to debug/run locally 2) Modify the Azure Bot Channel Service settings to change the endpoint to the one indicated by Ngrok 3) register an email channel 4) run the code locally from Visual Studio Community 5) send an email to the registered bot email address 6) wait a couple of minutes (could be from 1 to 10 minutes) 7) hundreds of emails received

Here's the entire main dialog code :

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using EchoTest.Dialogs;
using EchoTest.EmailJson;
using EchoTest.State;
using EchoTest.Utils;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Choices;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

namespace EchoTest
{
    /// <summary>
    /// Represents a bot that processes incoming activities.
    /// For each user interaction, an instance of this class is created and the OnTurnAsync method is called.
    /// This is a Transient lifetime service.  Transient lifetime services are created
    /// each time they're requested. For each Activity received, a new instance of this
    /// class is created. Objects that are expensive to construct, or have a lifetime
    /// beyond the single turn, should be carefully managed.
    /// For example, the <see cref="MemoryStorage"/> object and associated
    /// <see cref="IStatePropertyAccessor{T}"/> object are created with a singleton lifetime.
    /// </summary>
    /// <seealso cref="https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-2.1"/>
    public class EchoTestBot : IBot
    {
        // Define the IDs for the dialogs in the bot's dialog set.
        private const string MainDialogId = "mainDialog";
        private const string TicketDialogId = "ticketDialog";
        private const string FAQDialogId = "faqDialog";
        private const string AlarmDialogId = "alarmDialog";
          private const string EmailDialogNestedId = "emailnestedDialog";

        // Define the dialog set for the bot.
        private readonly DialogSet _dialogs;

        // Define the state accessors and the logger for the bot.
        private readonly BotAccessors _accessors;
        private readonly ILogger _logger;

        /// <summary>
        /// Initializes a new instance of the <see cref="HotelBot"/> class.
        /// </summary>
        /// <param name="accessors">Contains the objects to use to manage state.</param>
        /// <param name="loggerFactory">A <see cref="ILoggerFactory"/> that is hooked to the Azure App Service provider.</param>
        public EchoTestBot(BotAccessors accessors, ILoggerFactory loggerFactory)
        {
            if (loggerFactory == null)
            {
                throw new System.ArgumentNullException(nameof(loggerFactory));
            }

            _logger = loggerFactory.CreateLogger<EchoTestBot>();
            _logger.LogTrace($"{nameof(EchoTestBot)} turn start.");
            _accessors = accessors ?? throw new System.ArgumentNullException(nameof(accessors));

            // Define the steps of the main dialog.
            WaterfallStep[] steps = new WaterfallStep[]
            {
        MenuStepAsync,
        HandleChoiceAsync,
        LoopBackAsync,
            };

            // Create our bot's dialog set, adding a main dialog, an email dialog and the three component dialogs.
            _dialogs = new DialogSet(_accessors.DialogStateAccessor)
                .Add(new WaterfallDialog(MainDialogId, steps))
                .Add(new EmailDialog(EmailDialogNestedId))
                .Add(new TicketDialog(TicketDialogId))
                .Add(new FAQDialog(FAQDialogId))
                .Add(new SetAlarmDialog(AlarmDialogId));
        }

        private static async Task<DialogTurnResult> MenuStepAsync(
            WaterfallStepContext stepContext,
            CancellationToken cancellationToken = default(CancellationToken))
        {

            if (stepContext.Context.Activity.ChannelId == "email")
            {

                // Receives the ChannelData Json string, deserialize it into a ChannelDataJson object and cast it into a context.value to send it to the component Dialog
                stepContext.Values["channelData"] = JsonConvert.DeserializeObject<ChannelDataJson>(stepContext.Context.Activity.ChannelData.ToString());
                //((ChannelDataJson)stepContext.Values["channelData"]);
                var h = 0;
                await stepContext.BeginDialogAsync(EmailDialogNestedId, stepContext.Values["channelData"]);
                return await stepContext.EndDialogAsync();
            }
            else
            { await stepContext.ContinueDialogAsync(); }
            // Present the user with a set of "suggested actions".
            List<string> options = new List<string> { "Check INC/CHG/RITM Status", "FAQ", "Chat with you (Under Construction)" };
            await stepContext.Context.SendActivityAsync(
                MessageFactory.SuggestedActions(options, "How can I help you?"),
                cancellationToken: cancellationToken);
            return Dialog.EndOfTurn;
        }

        private async Task<DialogTurnResult> HandleChoiceAsync(
            WaterfallStepContext stepContext,
            CancellationToken cancellationToken = default(CancellationToken))
        {
            // Get the user's info. (Since the type factory is null, this will throw if state does not yet have a value for user info.)
            UserInfo userInfo = await _accessors.UserInfoAccessor.GetAsync(stepContext.Context, null, cancellationToken);

            // Check the user's input and decide which dialog to start.
            // Pass in the guest info when starting either of the child dialogs.
            string choice = (stepContext.Result as string)?.Trim()?.ToLowerInvariant();
            switch (choice)
            {
                case "check inc/chg/ritm status":
                    return await stepContext.BeginDialogAsync(TicketDialogId, userInfo, cancellationToken);

                case "faq":
                    return await stepContext.BeginDialogAsync(FAQDialogId, userInfo, cancellationToken);

                case "chat with you (under construction)":
                    return await stepContext.BeginDialogAsync(AlarmDialogId, userInfo.Guest, cancellationToken);

                default:
                    // If we don't recognize the user's intent, start again from the beginning.
                    await stepContext.Context.SendActivityAsync(
                        "Sorry, I don't understand that command. Please choose an option from the list.");
                    return await stepContext.ReplaceDialogAsync(MainDialogId, null, cancellationToken);
            }
        }

        private async Task<DialogTurnResult> LoopBackAsync(
            WaterfallStepContext stepContext,
            CancellationToken cancellationToken = default(CancellationToken))
        {
            // Get the user's info. (Because the type factory is null, this will throw if state does not yet have a value for user info.)
            UserInfo userInfo = await _accessors.UserInfoAccessor.GetAsync(stepContext.Context, null, cancellationToken);
            var h = 0;
            // Process the return value from the child dialog.
            switch (stepContext.Result)
            {
                //case TableInfo table:
                //    // Store the results of the reserve-table dialog.
                //    userInfo.Table = table;
                //    await _accessors.UserInfoAccessor.SetAsync(stepContext.Context, userInfo, cancellationToken);
                //    break;
                //case WakeUpInfo alarm:
                //    // Store the results of the set-wake-up-call dialog.
                //    userInfo.WakeUp = alarm;
                //    await _accessors.UserInfoAccessor.SetAsync(stepContext.Context, userInfo, cancellationToken);
                //    break;
                //The TicketDialog returns a FailoverTemp object and this case is activated
                case FailoverTemp failover:
                    // If the user failed to enter a valid ticket number, the FailoverTemp object returned will have a value of 1 and this if is activated.
                    if (failover.failOver == 1)
                    {

                        //We are using the UserInfo accessor to store persistent RetryAttempts so we can do something about it.
                        userInfo.RetryAttempts++;
                        await _accessors.UserInfoAccessor.SetAsync(stepContext.Context, userInfo, cancellationToken);
                    }
                    //else
                    //{
                    //    //if the user entered a valid ticket number the TicketDialog should return a FailoverTemp value of 0 and no retryattempt should be logged
                    //    await _accessors.UserInfoAccessor.SetAsync(stepContext.Context, userInfo, cancellationToken);
                    //}
                    break;

                default:
                    // We shouldn't get here, since these are no other branches that get this far.
                    break;
            }

            // Restart the main menu dialog.
            return await stepContext.ReplaceDialogAsync(MainDialogId, null, cancellationToken);
        }

        // Below starts the Email Dialog Waterfall steps. This will only trigger if the onTurnAsync detects the incoming activity message is of "email" channelid

            /// <summary>
            /// Every conversation turn for our Echo Bot will call this method.
            /// There are no dialogs used, since it's "single turn" processing, meaning a single
            /// request and response.
            /// </summary>
            /// <param name="turnContext">A <see cref="ITurnContext"/> containing all the data needed
            /// for processing this conversation turn. </param>
            /// <param name="cancellationToken">(Optional) A <see cref="CancellationToken"/> that can be used by other objects
            /// or threads to receive notice of cancellation.</param>
            /// <returns>A <see cref="Task"/> that represents the work queued to execute.</returns>
            /// <seealso cref="BotStateSet"/>
            /// <seealso cref="ConversationState"/>
            /// <seealso cref="IMiddleware"/>
            public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            if (turnContext.Activity.Type == ActivityTypes.Message)
            {

                // Establish dialog state from the conversation state.
                DialogContext dc = await _dialogs.CreateContextAsync(turnContext, cancellationToken);

                // Get the user's info.
                UserInfo userInfo = await _accessors.UserInfoAccessor.GetAsync(turnContext, () => new UserInfo(), cancellationToken);

                await _accessors.UserInfoAccessor.SetAsync(turnContext, userInfo, cancellationToken);

                // Continue any current dialog.
                DialogTurnResult dialogTurnResult = await dc.ContinueDialogAsync();

                // Process the result of any complete dialog.
                if (dialogTurnResult.Status is DialogTurnStatus.Complete)
                {
                    var i = 0;
                    //switch (dialogTurnResult.Result)
                    //{
                    //    case GuestInfo guestInfo:
                    //        // Store the results of the check-in dialog.
                    //        userInfo.Guest = guestInfo;
                    //        await _accessors.UserInfoAccessor.SetAsync(turnContext, userInfo, cancellationToken);
                    //        break;

                    //    default:
                    //        // We shouldn't get here, since the main dialog is designed to loop.
                    //        break;
                    //}
                }

                // Every dialog step sends a response, so if no response was sent,
                // then no dialog is currently active.

                else if (!turnContext.Responded)
                {
                    var h = 0;
                    // if the user attempted to many times to enter an invalid ticket, this condition is met and 
                     the if should open a Too many attempts dialog.
                    //if (userInfo.RetryAttempts > 3)
                    //{
                        //We need to think how to handle too many attemps.
                        await dc.BeginDialogAsync(MainDialogId, null, cancellationToken);
                    //}
                    //else
                    //{
                    //    // Otherwise, start our bot's main dialog.
                    //    await dc.BeginDialogAsync(MainDialogId, null, cancellationToken);
                    //}
                }

                // Save the new turn count into the conversation state.
                await _accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
                await _accessors.UserState.SaveChangesAsync(turnContext, false, cancellationToken);
            }

            else
            {
                // Commenting this to avoid "event detected" message over the chat.
               // await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
            }
        }
    }
}

Here's the Email component Dialog that sends the reply:

using EchoTest.Utils;
using MarvinModels;
using MarvinServices;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Choices;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace EchoTest.Dialogs
{
    public class EmailDialog : ComponentDialog
    {

        // Nested Dialog Id, required to be used with the ReplaceDialog
        private const string EmailDialogId = "emailDialogId";

        public EmailDialog(string id) // id inhereted from the parent Dialog.
            : base(id)
        {
            // The InitialDialogId needs to be set to the ID of a dialog in the nested/child dialog, the one to start when the waterfall starts
            InitialDialogId = EmailDialogId;

            // Define the prompts used in this conversation flow.
            //AddDialog(new ChoicePrompt(FAQPrompt));
            //AddDialog(new ConfirmPrompt(RepeatPrompt));

            // Define the conversation flow using a waterfall model.
            WaterfallStep[] waterfallSteps = new WaterfallStep[]
            {
                CheckQuestionAsync,
                FAQChoicePromptAsync,

            };
            AddDialog(new WaterfallDialog(EmailDialogId, waterfallSteps));

        }

        private static async Task<DialogTurnResult> CheckQuestionAsync(
        WaterfallStepContext stepContext,
        CancellationToken cancellationToken = default(CancellationToken))
        {
            ChannelDataJson email = new ChannelDataJson();
            stepContext.Values["emaildata"] = stepContext.Options;
            var j = 0;
            email = ((ChannelDataJson)stepContext.Values["emaildata"]);

            await stepContext.Context.SendActivityAsync(email.TextBody.Text);
            var h = 0;
            //var strings = ChannelDataEmail.ToString();
            //var j = 0;
            //ChannelDataJson EmailObject = new ChannelDataJson();

            //EmailObject = JsonConvert.DeserializeObject<ChannelDataJson>(strings);

            return await stepContext.EndDialogAsync();
        }

        private static async Task<DialogTurnResult> FAQChoicePromptAsync(
       WaterfallStepContext stepContext,
       CancellationToken cancellationToken = default(CancellationToken))
        {

            //return await stepContext.PromptAsync(FAQPrompt,
            //    new PromptOptions
            //    {
            //        Prompt = MessageFactory.Text("What do you want to know?"),
            //        RetryPrompt = MessageFactory.Text("Selected Option not available . Please try again."),
            //        Choices = ChoiceFactory.ToChoices(questions),

            //    },
            //cancellationToken);
            //await stepContext.Context.SendActivityAsync(
                 "What do you want to know?");
            return await stepContext.EndDialogAsync();

        }

    }
}

Let me know if you need anything else to check this.

DanteNahuel commented 5 years ago

I modified my code completely and "solved" this. I'm saying "solved" because although now the bot doesn't loop for ever, it does do a strange thing, but i'll open a new incident for it. I'm pasting the code here to help anyone else that searches for this issue. I still don't know why the previous code caused this.

OnTurnAsync:

 public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            if (turnContext.Activity.Type == ActivityTypes.Message && turnContext.Activity.ChannelId != "email")
            {

                // Establish dialog state from the conversation state.
                DialogContext dc = await _dialogs.CreateContextAsync(turnContext, cancellationToken);

                // Get the user's info.
                UserInfo userInfo = await _accessors.UserInfoAccessor.GetAsync(turnContext, () => new UserInfo(), cancellationToken);

                await _accessors.UserInfoAccessor.SetAsync(turnContext, userInfo, cancellationToken);

                // Continue any current dialog.
                DialogTurnResult dialogTurnResult = await dc.ContinueDialogAsync();

                // Process the result of any complete dialog.
                if (dialogTurnResult.Status is DialogTurnStatus.Complete)
                {
                    var i = 0;
                    await turnContext.SendActivityAsync("Thank you, see you next time");

                }

                // Every dialog step sends a response, so if no response was sent,
                // then no dialog is currently active.

                else if (!turnContext.Responded)
                {
                    var h = 0;
                    // if the user attempted to many times to enter an invalid ticket, this condition is met and the if should open a Too many attempts dialog.
                    //if (userInfo.RetryAttempts > 3)
                    //{
                    //We need to think how to handle too many attemps.
                    await dc.BeginDialogAsync(MainDialogId, null, cancellationToken);

                    //}
                    //else
                    //{
                    //    // Otherwise, start our bot's main dialog.
                    //    await dc.BeginDialogAsync(MainDialogId, null, cancellationToken);
                    //}
                }

                // Save the new turn count into the conversation state.
                await _accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
                await _accessors.UserState.SaveChangesAsync(turnContext, false, cancellationToken);
            }
            else if (turnContext.Activity.Type == ActivityTypes.Message && turnContext.Activity.ChannelId == "email")
            {
                // Establish dialog state from the conversation state.
                DialogContext dc = await _dialogs.CreateContextAsync(turnContext, cancellationToken);

                // Get the user's info.
                UserInfo userInfo = await _accessors.UserInfoAccessor.GetAsync(turnContext, () => new UserInfo(), cancellationToken);

                await _accessors.UserInfoAccessor.SetAsync(turnContext, userInfo, cancellationToken);

                // Continue any current dialog.
                DialogTurnResult dialogTurnResult = await dc.ContinueDialogAsync();

                // Process the result of any complete dialog.
                if (dialogTurnResult.Status is DialogTurnStatus.Complete)
                {

                }

                // Every dialog step sends a response, so if no response was sent,
                // then no dialog is currently active.

                else if (!turnContext.Responded)
                {

                    var h = 0;
                    // if the user attempted to many times to enter an invalid ticket, this condition is met and the if should open a Too many attempts dialog.
                    //if (userInfo.RetryAttempts > 3)
                    //{
                    //We need to think how to handle too many attemps.
                    await dc.BeginDialogAsync(EmailDialogId, null, cancellationToken);

                    //}
                    //else
                    //{
                    //    // Otherwise, start our bot's main dialog.
                    //    await dc.BeginDialogAsync(MainDialogId, null, cancellationToken);
                    //}
                }

                // Save the new turn count into the conversation state.
                await _accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
                await _accessors.UserState.SaveChangesAsync(turnContext, false, cancellationToken);
            }

            else
            {
                // Commenting this to avoid "event detected" message over the chat.
                // await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");

            }
        }

Email Dialog:

using EchoTest.Utils;
using MarvinModels;
using MarvinServices;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Choices;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace EchoTest.Dialogs
{
    public class EmailDialog : ComponentDialog
    {
        private readonly BotAccessors _accessors;

        // Nested Dialog Id, required to be used with the ReplaceDialog
        private const string EmailDialogId = "emailDialogId";

        public EmailDialog(string id, BotAccessors accessors) // id inhereted from the parent Dialog.
            : base(id)
        {
            // The InitialDialogId needs to be set to the ID of a dialog in the nested/child dialog, the one to start when the waterfall starts
            InitialDialogId = EmailDialogId;
            _accessors = accessors;
            // Define the prompts used in this conversation flow.
            //AddDialog(new ChoicePrompt(FAQPrompt));
            //AddDialog(new ConfirmPrompt(RepeatPrompt));

            // Define the conversation flow using a waterfall model.
            WaterfallStep[] waterfallSteps = new WaterfallStep[]
            {
                CheckQuestionAsync,
                FAQChoicePromptAsync,

            };
            AddDialog(new WaterfallDialog(EmailDialogId, waterfallSteps));

        }

        private async Task<DialogTurnResult> CheckQuestionAsync(
        WaterfallStepContext stepContext,
        CancellationToken cancellationToken = default(CancellationToken))
        {

            stepContext.Values["emaildata"] = stepContext.Context.Activity.ChannelData;

            var json = stepContext.Values["emaildata"];

            //await stepContext.Context.SendActivityAsync(email.TextBody.Text);
            var h = 0;
            var strings = json.ToString();
            var j = 0;
            ChannelDataJson EmailObject = new ChannelDataJson();

            EmailObject = JsonConvert.DeserializeObject<ChannelDataJson>(strings);
            await stepContext.Context.SendActivityAsync(EmailObject.TextBody.Text);
            return await stepContext.ContinueDialogAsync();
        }

        private static async Task<DialogTurnResult> FAQChoicePromptAsync(
       WaterfallStepContext stepContext,
       CancellationToken cancellationToken = default(CancellationToken))
        {

            //return await stepContext.PromptAsync(FAQPrompt,
            //    new PromptOptions
            //    {
            //        Prompt = MessageFactory.Text("What do you want to know?"),
            //        RetryPrompt = MessageFactory.Text("Selected Option not available . Please try again."),
            //        Choices = ChoiceFactory.ToChoices(questions),

            //    },
            //cancellationToken);
            //await stepContext.Context.SendActivityAsync(
            //     "What do you want to know?");
            return await stepContext.EndDialogAsync();

        }

    }
}
jwiley84 commented 5 years ago

@DanteNahuel I'm going to close this issue, since it's related to the issue you open above (#1347). Will be working on seeing what's going on that thread.