I am currently using the Microsoft Bot Framework with node.js to write a bot that traverses a decision tree through an API. I want to allow the user to cancel the tree at any point in the dialog, so that he doesn't have to go through the entire (possibly huge) tree to exit. Because i need to send a message to the API if the session is closed, i am using Dialog.beginDialogAction()
to start a "cancel"-Dialog, instead of Dialog.cancelAction()
, so that i can prompt the user to confirm as well as close the session.
Now that i have that in place, canceling the tree works just fine, but if the user chooses to say "no" to the confirmation prompt and the bot should actually re-prompt the last question, it sometimes uses the "no" to answer the previous question automatically (or throws an error). This only appears if the valueType of the question is "string" and a Prompts.choice
dialog is shown, for Prompts.number
and Prompts.time
the expected behaviour is produced.
I have searched any Documentation i could find, but no information about some Prompts not supporting DialogActions or anything like that. I am only using session.beginDialog
, session.endDialog
, session.endConversation
and builder.Prompts
to control the dialog stack.
Code looks like this:
//This dialog gets the next question node of the tree passed in args[1].
bot.dialog('select', [
function(session, args, next) {
//Depending on the value type of the question, different prompts are used.
switch (args[1].valueType) {
case "string":
builder.Prompts.choice(session, args[1].text, args[1].answerValues, {
listStyle: builder.ListStyle.button,
maxRetries: 0
});
break;
case "number":
builder.Prompts.number(session, args[1].text, {
integerOnly: true,
maxValue: 100,
minValue: 0
})
break;
case "datetime":
builder.Prompts.time(session, message + "\nPlease enter a date in the format 'yyyy-mm-dd'.");
break;
}
},
function(session, results) {
//Use the answer to proceed the tree.
//...
//With the new question node start over.
session.beginDialog('select', [sessionId, questionNode]);
}
]).beginDialogAction('cancelSelect', 'cancel', {
matches: /^stop$/i
});
//This dialog is used for cancelling.
bot.dialog('cancel', [
function(session, next) {
builder.Prompts.confirm(session, 'Do you really want to quit?');
},
function(session, results) {
if (results.response) {
finishSession();
session.endConversation('Session was closed.')
} else {
//Here the bot should stop this cancel-Dialog and reprompt the previous question
session.endDialog();
}
}
])
But instead of re-prompting, the bot jumps to function (session, results)
in the 'select'-Dialog, where it tries to parse the answer "no" and obviously fails.
Here the full copy of my app.js. You wont be able to run it without mocking the esc-API of our product, but it shows that i am only using session.beginDialog
, session.endDialog
, session.endConversation
and builder.Prompts
. The only changes i did were to remove private information and translate messages to english.
/*---------
-- Setup --
---------*/
//esc and esc_auth are product specific, so obviously i cant share them. They handle the API of our product.
const esc = require("./esc");
const esc_auth = require("./esc_auth");
var restify = require("restify");
var builder = require("botbuilder");
var server = restify.createServer();
server.listen(process.env.PORT || process.env.port || 3978, function() {
console.log(`${server.name} listening to ${server.url}`);
});
var connector = new builder.ChatConnector({
//Cant share these as well
appId: "",
appPassword: ""
});
server.post("/api/messages", connector.listen());
var esc_header;
var esc_session;
var esc_attribs = {};
var treeNames = [];
/*---------
-- Start --
---------*/
//This function is called when no other dialog is currently running.
//It gets the authorization token from the API, reads concepts from the input and searches for matching decision trees.
//If not concepts or trees were found, a text search on the API is cone.
var bot = new builder.UniversalBot(connector, [
function(session) {
var esc_token;
esc_attribs = {};
console.log("Getting token...");
esc.escAccessToken(esc_auth.esc_system, esc_auth.esc_apiUser)
.then(function(result) {
esc_token = result;
esc_header = {
"Content-Type": "application/json",
"Authorization": "Bearer " + esc_token
};
console.log("Got token.");
//Look for concepts in the message.
esc.escAnnotateQuery(esc_header, session.message.text)
.then(function(result) {
for(i in result.concepts) {
for(j in result.concepts[i]) {
esc_attribs[i] = j;
}
}
//If concepts were found, look for trees and solutions with them
if(Object.keys(esc_attribs).length > 0) {
esc.escSearchIndexWithConcepts(esc_header, esc_attribs)
.then(function(result) {
var treeIds = [];
treeNames = [];
result = result;
result.records.forEach(function(entry) {
//Check which tree the found tree is or found solution is in.
if(entry.DecisionTree && !treeIds.includes(entry.DecisionTree)) {
treeIds.push(entry.DecisionTree);
}
})
if(treeIds.length != 0) {
esc.escSearchTrees(esc_header)
.then(function(result) {
console.log("Trees found.");
result.records.forEach(function(tree) {
if(treeIds.includes(tree.DecisionTree)) {
treeNames.push({id:tree.DecisionTree, name: tree.Label})
console.log("Tree: ", tree.DecisionTree, tree.Label);
}
})
session.beginDialog("tree", treeNames);
})
} else {
console.log("No tree found for " + session.message.text);
treeNames = [];
session.beginDialog("textSearch");
return;
}
})
} else {
console.log("No concepts found.");
session.beginDialog("textSearch");
return;
}
})
})
},
function(session, results) {
session.endConversation("You may now start a new search.");
}
]);
//Searches for trees by text.
bot.dialog("textSearch", [
function(session) {
session.send("No concepts were found in your input.");
builder.Prompts.confirm(session, "Start a text search instead?", {"listStyle": builder.ListStyle.button});
},
function(session, results) {
if(results.response) {
builder.Prompts.text(session, "Please enter your new search prompt in keywords.")
} else {
session.endDialog("Ok, back to concept search.")
}
},
function(session) {
//Search gives better results without mutated vowels
esc.escSearchIndex(esc_header, undoMutation(session.message.text))
.then(function(result) {
var treeIds = [];
treeNames = [];
result.records.forEach(function(entry) {
//Check which tree the found document is in.
if(entry.DecisionTree && !treeIds.includes(entry.DecisionTree)) {
treeIds.push(entry.DecisionTree);
}
})
if(treeIds.length != 0) {
esc.escSearchTrees(esc_header)
.then(function(result) {
console.log("Trees found.");
result.records.forEach(function(tree) {
if(treeIds.includes(tree.DecisionTree)) {
treeNames.push({id:tree.DecisionTree, name: tree.Label})
console.log("Tree: ", tree.DecisionTree, tree.Label);
}
})
session.beginDialog("tree", treeNames);
})
} else {
console.log("No tree found for " + session.message.text);
treeNames = [];
session.endConversation("No trees were found for this search.");
}
})
}
])
//The cancel dialog.
bot.dialog("cancel", [
function(session) {
builder.Prompts.confirm(session, "Do you really want to cancel?", {"listStyle": builder.ListStyle.button});
},
function(session, results) {
if(results.response) {
if(esc_session) {
esc.escFinishSession(esc_header, esc_session.sessionId)
.then(function(result) {
esc_session = undefined;
session.endConversation("Session was cancelled.")
})
} else {
session.endConversation("Session was cancelled.")
}
} else {
session.endDialog();
}
}
])
/*-------------------------
-- Decision tree dialogs --
-------------------------*/
//This dialog presents the found decision trees and lets the user select one.
bot.dialog("tree", [
function(session, treeArray) {
var opts = [];
treeArray.forEach(function(t) {
opts.push(t.name);
});
builder.Prompts.choice(session, "Following trees were found:", opts, {listStyle: builder.ListStyle.button})
},
function(session, results) {
let answer = results.response.entity;
console.log("Tree selected:", answer);
let id;
treeNames.forEach(function(t) {
if(t.name === answer && !id) {
id = t.id;
}
})
console.log("Starting session...");
esc.escStartSession(esc_header, id, esc_attribs)
.then(function(result) {
esc_session = result;
for(i in esc_session.concepts) {
for(j in esc_session.concepts[i]) {
esc_attribs[i] = j;
}
}
console.log("Started session.");
session.beginDialog(esc_session.questions[0].nodeType,[esc_session.sessionId, esc_session.questions[0]]);
})
.catch(function(err) {
console.log("Error starting ESC session.");
console.log(err);
})
}
]).beginDialogAction("cancelTree", "cancel", {matches: /^cancel$|^end$|^stop$|^halt/i});
//This dialog is for selection answers on a question node. It also saves recognized concepts within the answer.
bot.dialog("select", [
function(session, args) {
console.log("Select");
message = args[1].text;
attach(args[1].memo["Memo_URL"]);
session.userData = args;
var opts = new Array();
switch(args[1].valueType) {
case "string":
for(var i = 0; i < args[1].answerValues.length; i++) {
opts[i] = args[1].answerValues[i].value;
}
builder.Prompts.choice(session, message, opts, {listStyle: builder.ListStyle.button, maxRetries: 0});
break;
case "number":
for(var i = 0; i < args[1].answerIntervals.length; i++) {
opts[i] = args[1].answerIntervals[i].value;
}
builder.Prompts.number(session, message, {integerOnly: true, maxValue: 100, minValue: 0});
break;
case "datetime":
for(var i = 0; i < args[1].answerIntervals.length; i++) {
opts[i] = args[1].answerIntervals[i].value;
}
builder.Prompts.time(session, message + "\nPlease enter a date in format 'yyyy-mm-dd'.");
break;
}
},
function(session, results) {
let args = session.userData;
let answer;
//An answer was given.
if(results.response != null && results.response.entity != null) {
answer = results.response.entity;
} else if (results.response != null) {
answer = results.response;
} else {
//No answer (to a choice prompt) was given, check if concepts were recognized and try again.
}
esc.escAnnotateQuery(esc_header, session.message.text)
.then(function(result) {
for(i in result.concepts) {
for(j in result.concepts[i]) {
esc_attribs[i] = j;
}
}
console.log("Proceeding tree with answer %s", answer);
esc.escProceedTree(esc_header, args[0], args[1].nodeId, args[1].treeId, answer, esc_attribs)
.then(function(result) {
if(result.questions[0].nodeType === "error") {
//If no concept answers the question, ask again.
session.send("No answer was given.")
session.beginDialog("select", [esc_session.sessionId, esc_session.questions[0]])
} else {
esc_session = result;
console.log("Initiating new Dialog %s", esc_session.questions[0].nodeType);
//the nodeType is either "select", "info" or "solution"
session.beginDialog(esc_session.questions[0].nodeType, [esc_session.sessionId, esc_session.questions[0]])
}
})
.catch(function(err) {
console.log("Error proceeding tree.");
console.log(err);
});
})
}
]).beginDialogAction("cancelSelect", "cancel", {matches: /^abbrechen$|^beenden$|^stop$|^halt/i});
//This dialog is for showing hint nodes. It then automatically proceeds the tree.
bot.dialog("info", [
function(session, args) {
console.log("Info");
message = args[1].text;
attach(args[1].memo["Memo_URL"]);
session.send(message);
console.log("Proceeding tree without answer.");
esc.escProceedTree(esc_header, args[0], args[1].nodeId, args[1].treeId, "OK", esc_attribs)
.then(function(result) {
esc_session = result;
console.log("Initiating new Dialog %s", esc_session.questions[0].nodeType);
session.beginDialog(esc_session.questions[0].nodeType, [esc_session.sessionId, esc_session.questions[0]]);
})
.catch(function(err) {
console.log("Error proceeding tree.");
console.log(err);
});
}
])
//This dialog displays the reached solution. It then ends the dialog, erasing the concepts of this session.
bot.dialog("solution", [
function(session, args) {
console.log("Solution");
message = args[1].text;
attach(args[1].memo["Memo_URL"]);
session.send(message);
esc.escFinishSession(esc_header, args[0])
.then(function(result) {
console.log("Finished Session " + args[0]);
esc_session = undefined;
})
.catch(function(err) {
console.log("Error finishing session.");
console.log(err);
})
console.log("Ending dialog.");
session.endDialog("I hope i could help you.");
}
])
/*-----------
-- Manners --
-----------*/
// Greetings
bot.on("conversationUpdate", function (message) {
if (message.membersAdded && message.membersAdded.length > 0) {
// Don"t greet yourself
if(message.membersAdded[0].id !== message.address.bot.id) {
// Say hello
var reply = new builder.Message()
.address(message.address)
.text("Welcome to the Chatbot. Please enter your search.");
bot.send(reply);
}
} else if (message.membersRemoved) {
// Say goodbye
var reply = new builder.Message()
.address(message.address)
.text("Goodbye.");
bot.send(reply);
}
});
/*---------------------
-- Utility functions --
---------------------*/
//function for attached images
var attach = function(p) {
if(typeof p != undefined && p != null) {
console.log("Found attachment: %s", p);
session.send({
attachments:[{
contentUrl: p,
contentType: "image/jpeg"
}]
})
}
}
var undoMutation = function(s) {
while(s.indexOf("ä") !== -1) {
s = s.replace("ä", "ae");
}
while(s.indexOf("ö") !== -1) {
s = s.replace("ö", "oe");
}
while(s.indexOf("ü") !== -1) {
s = s.replace("ü", "ue");
}
return s;
}
It could be the question of nested string Prompt dialogs. Currently I only find some workarounds to quick fix the issue.
Fastest: Enlarge the retires of prompt dialog, which will reprompt the question if Bot gets the invaild input.
builder.Prompts.choice(session, args[1].text, args[1].answerValues, {
listStyle: builder.ListStyle.button,
maxRetries: 2
});
As I see you will pass the treeNote
to the select dialog, so you can try to use replaceDialog()
instead of beginDialog()
to clear nested dialog stack. In your cancel
dialog, also replace endDialog()
to replaceDialog()