Lego Party is my side hustle — Lego-themed entertainment like parties, classes, boozy Lego building, therapy, you name it! My last article took this business into the 21st Century (and let me stop taking calls during my day job) when we created an online class calendar with Acuity Scheduling.
Folks find businesses online. That's hardly news, but more and more it isn't enough to just be online. Businesses need to meet the customer on their own turf. Today Lego Party is making that next step, and bringing online scheduling directly to people on Facebook using Acuity Scheduling's appointment scheduling API. We'll also need a conversational AI platform such as Init.ai, Wit.ai and api.ai to name a few. For this project, I've selected Init.ai's conversational API because of their nice SDK and straight forward Facebook Messenger connection.
Overview
We'll be building a conversational chat bot for Lego Party's Facebook page to handle the two most common scheduling tasks for clients:
- Booking new classes
- Checking their booked classes
We'll need both an Acuity Scheduling account and an Init.ai account (no credit cards required!). Acuity Scheduling is an online appointment scheduler with all the bells and whistles Lego Party needs, as well as a developer friendly scheduling API for checking upcoming classes and creating new bookings. Init.ai is a developer platform for building conversational apps powered by Natural Language Processing, with direct integration to Facebook Messenger.
The application itself will consist of a single server component written in Node.js using Acuity's SDK, Init.ai's SDK, Moment.js and a sample app from Init.ai. And we'll be building it to do this:
Let's get started!
Acuity Scheduling
Get started with Acuity Scheduling by signing up for a free trial here. Feel free to poke around! But for this project we'll need some classes, so don't leave without creating a couple "Class" appointment types, and be sure to offer a few sessions for each class. Here's the upcoming class schedule folks see when they visit my client scheduling page:
Once you have your classes set up, have a quick look at the scheduling APIs we'll be integrating:
- get appointment-types to provide a list of classes
- get availability/classes to fetch the dates and times of a class's sessions
- post appointments to book new classes
- get appointments to pull someone's upcoming appointments
Init.ai
Init.ai is in a free beta — signup only requires a github account. After signing up, create a new Project for Class Bookings. The complexity of dealing with the Init.ai is abstracted away nicely by their SDK so I won't focus on that here. Instead, there are a couple key concepts to keep in mind for the implementation:
- Training Conversations Example conversations used to define intents and entities and build a model.
- Intents Meanings of particular messages, such as a greeting or providing a particular piece of data.
- Entities Important words within a message, extracted as data and a corresponding type.
- Models Programs built by Init.ai from Training Conversations mapping input language to intents and entities, and vice-versa.
The first step in a new project is to create some training language — the more the better! Init.ai has a simple markup language for the task. Here's an example:
user> What time is my class?
* check
service> What is your e-mail address?
* prompt/email
user> [[email protected]](email/email)
* provide/email
service> You do not have any upcoming classes scheduled.
* upcoming/none
A
user kicks off a conversation, and the
service makes a reply. An
intent, marked with an asterisk, follows each message, eg.
* check
. And
entities, marked with brackets and followed by it's optional type and a name in parentheses, are defined inside messages eg.
[[email protected]](email/email)
.
For this project, you can find example training conversations in the github project. Add those to your new Init.ai project and Init.ai will automatically populate a list of intents and entities for you. Then just click "Train your model" in the main menu and Init.ai will build the model to power the application. That'll take a few minutes, but we can carry on in the meantime!
Finally under Settings, connect a messanging plugin. Init.ai has a built in "test" messenger you can find in the bottom right-hand corner of your Init.ai console. For this project we'll use Facebook Messenger, which supports a couple extra features that the test messenger doesn't. First you'll need a Facebook page — you can create a new one here for testing — then enable the Facebook Messenger connection.
For the rest of this project you'll interact with your chatbot through the Facebook Messenger. As soon as your Facebook page is connected, we'll start the server and build our application.
The Application
Now we're ready to dive into the code. First, we'll start up the server.
Starting The Server
We'll be starting with a sample project Init.ai has put together. Clone that repository to get started:
git clone [email protected]:init-ai/sample-logic-server.git
Now install base modules, and the couple additional modules we'll be using:
npm install && npm install --save acuityscheduling moment
We're almost ready to start the dev server. First, grab your Acuity user ID and API key from Business Settings - Integrations. We'll pass those into our server's environment now because we'll need them in a minute.
Then, to start the server run:
ACUITY_USER_ID=123 ACUITY_API_KEY=abc123 npm run dev
Our dev server will start a "webhook" tunnel to receive messages from Init.ai:
Just copy and paste that webhook into Settings under Webhooks in your Init.ai project. Note: Each time you restart the dev server, you'll need to update that webhook URL in your project settings!
Application Code
This sample application contains a bit of boilerplate code that's worth checking out, but everything we'll need to edit is in a single file: src/runLogic.js
. Code in this module will be executed each time we receive an event (eg. a message) from Init.ai through the webhook tunnel started by the server.
First things first, include the modules and a bit of config that we'll be working with:
/**
* Run Logic for Booking Appointments with Init.ai
*/
const InitClient = require('initai-node');
const AcuityScheduling = require('acuityscheduling');
const moment = require('moment');
const dateFormat = 'MMM D, h:mma';
This module itself exports a function which is run for each message we receive through Init.ai, and returns a promise:
module.exports = function runLogic(eventData) {
return new Promise((resolve) => {
// Application code goes here...
});
};
A lot of our service's intents are asynchronous, and will gather data from Acuity's API — Init.ai's SDK is built to help with this type of asynchronous task. In the promise body, create instances of the Acuity and Init.ai SDKs:
// Create Init.ai client
const client = InitClient.create(eventData, {succeed: resolve});
// Create an Acuity client
const acuity = new AcuityScheduling.basic({
"userId": process.env.ACUITY_USER_ID,
"apiKey": process.env.ACUITY_API_KEY
});
Each edit we make to this file triggers the development server to reload. Our Acuity credentials are already in the server's environment from when we started the server, so we're good to go! Now we can write the logic for the two conversation tasks:
- Booking new classes
- Checking their booked classes
Conversation Logic
Conversations in Init.ai are controlled by a flow. Tasks for a particular conversation, such as "book a class" or "check bookings", are called streams. Each stream can have multiple steps such as getEmailAddress. And each step usually corresponds to a pair of question-answer intents, processing the entities from a message to store data, request new data, etc.
All of this is set up in the client.runFlow
method, the main entry point for our Init.ai conversation logic. Add this down to the bottom of src/runLogic.js
and then we'll fill in the steps above it:
// Set up the logic for our flow.
//
// We have two separate conversation streams: the default stream for
// booking a new class, and a separate stream to get current bookings.
// Conversations default to the bookClass stream, unless we receive a
// 'check' intent. Then we'll kick off the getBookings stream.
client.runFlow({
classifications: {
'check': 'getBookings'
},
streams: {
main: 'bookClass',
bookClass: [getAppointmentType, getDatetime, getName, getEmail, bookAppointment],
getBookings: [getEmail, getAppointments]
}
});
Our two conversation types, or streams, are bookClass
and getBookings
. Most people will book a class before they check their bookings, so we define that as the default stream using the main
attribute. The classification
attribute lets us map other intents to specific streams. In our case, we'll map the check
intent to the getBookings
stream.
Step 1: getAppointmentType
Now we'll define our first step: getAppointmentType
. Add this step above the call to runFlow
:
//
// Steps:
//
const getAppointmentType = client.createStep({
/**
* Get the appointment type from the client response.
*/
extractInfo() { },
/**
* Satisfy this step once we have an appointment type:
*/
satisfied() { },
/**
* Prompt for an appointment type if we don't have one stored.
*/
prompt() { }
});
Each step has a few different pieces. In our application, we'll be using extractInfo
, satisfied
and prompt
. The extractInfo
method takes any entities and other data extracted from a message and decides what to do with them. Irregardless of where we are in a conversation, extractInfo
is run for each step when any message is received. Chatters can provide info in an unexpected order, or provide info for multiple steps all at once, but each step should only concern itself with that step's entities.
"Hello. My name is Inigo Montoya. You killed my father. Prepare to die."
Execution continues and runFlow
decides which stream we're in. Steps for the current stream are executed in order, and satisfied
is called. If the step has everything it needs and can be considered complete, satisfied
should return true. For the first step that is not satisfied, the prompt
method is called and execution finishes.
In our first step, we're extracting an appointment type from the response (hopefully!) —
/**
* Get the appointment type from the client response.
*/
extractInfo() {
// Store the appointment type ID in the conversation state
const data = client.getPostbackData();
if (data && data.appointmentTypeID) {
client.updateConversationState({
appointmentTypeID: data.appointmentTypeID
});
}
},
client.getPostbackData()
returns structured data from pre-defined replies in a prompt — we'll cover that in a minute. If we've received an appointment type ID, store it in the conversation state, akin to a session in a web application.
This step is considered satisfied once we have an appointmentTypeID
stored.
/**
* Satisfy this step once we have an appointment type:
*/
satisfied() { return Boolean(client.getConversationState().appointmentTypeID) },
If it's not satisfied, prompt
will be called. We'll fetch a list of appointment types from the Acuity API, filter it for public classes, and send a reply with those options to the chatter.
/**
* Prompt for an appointment type if we don't have one stored.
*/
prompt() {
// Fetch appointment types from Acuity
acuity.request('/appointment-types', function (err, res, appointmentTypes) {
// Build some buttons for folks to choose a class
const replies = appointmentTypes
// Filter types for public classes
.filter(appointmentType => appointmentType.type === 'class' && !appointmentType.private)
// Create a button for each type
.map(appointmentType => client.makeReplyButton(
appointmentType.name,
null,
'bookClass',
{appointmentTypeID: appointmentType.id}
));
// Set the response intent to prompt to choose a type
client.addResponseWithReplies('prompt/type', null, replies);
// End the asynchronous prompt
client.done();
});
}
A nice feature of the Facebook Messenger is the ability to create convenient reply buttons for the user.
client.makeReplyButton( /* button text */, /* image */, /* stream */, /* structured data */ )
Our appointment type prompt creates a button for each class which displays the class name, and has the appointmentTypeID
stored. When the user selects a button, we'll receive a reply with that data from that getPostbackData
method called in extractInfo
.
The service's reply is defined with client.addResponseWithReplies
, sending the prompt/type
intent and the class buttons:
client.addResponseWithReplies('prompt/type', null, replies);
Last of all client.done();
is called. We made an asynchronous request to the Acuity API. client.done()
resolves the promise, and the service ships the response back to the user.
Here's the complete getAppointmentType
step:
const getAppointmentType = client.createStep({
/**
* Satisfy this step once we have an appointment type:
*/
satisfied() { return Boolean(client.getConversationState().appointmentTypeID) },
/**
* Get the appointment type from the client response.
*/
extractInfo() {
// Store the appointment type ID in the conversation state
const data = client.getPostbackData();
if (data && data.appointmentTypeID) {
client.updateConversationState({
appointmentTypeID: data.appointmentTypeID
});
}
},
/**
* Prompt for an appointment type if we don't have one stored.
*/
prompt() {
// Fetch appointment types from Acuity
acuity.request('/appointment-types', function (err, res, appointmentTypes) {
// Build some buttons for folks to choose a class
const replies = appointmentTypes
// Filter types for public classes
.filter(appointmentType => appointmentType.type === 'class' && !appointmentType.private)
// Create a button for each type
.map(appointmentType => client.makeReplyButton(
appointmentType.name,
null,
'bookClass',
{appointmentTypeID: appointmentType.id}
));
// Set the response intent to prompt to choose a type
client.addResponseWithReplies('prompt/type', null, replies);
// End the asynchronous prompt
client.done();
});
}
});
Step 2: getDatetime
Our next step is to pick a particular class session. Similar to appointment types, we'll grab available class sessions from the Acuity API and provide a list of convenient buttons to choose from. We'll extract the session datetime
from the postback data and satisfy the step once the datetime is saved:
const getDatetime = client.createStep({
satisfied() { return Boolean(client.getConversationState().datetime) },
extractInfo() {
// Store the datetime in the conversation state
const data = client.getPostbackData();
if (data && data.datetime) {
client.updateConversationState({
datetime: data.datetime
});
}
},
prompt() {
// Fetch available class sessions from Acuity using the appointment type:
const state = client.getConversationState();
const options = {
qs: {
month: moment().format('YYYY-MM'),
appointmentTypeID: state.appointmentTypeID
}
};
acuity.request('/availability/classes', options, function (err, res, sessions) {
// Build buttons for choosing a class session:
const replies = sessions.map(session => client.makeReplyButton(
moment(session.time).format(dateFormat),
null,
'bookClass',
{datetime: session.time}
));
// Ship the response:
client.addResponseWithReplies('prompt/datetime', null, replies);
client.done();
});
}
});
For fetching the session info from Acuity, we'll use the get availability/classes
endpoint for the current month and the selected appointment type from the first step.
const options = {
qs: {
month: moment().format('YYYY-MM'),
appointmentTypeID: state.appointmentTypeID
}
};
acuity.request('/availability/classes', options, ...
Step 3: getEmail
Unlike the previous two steps which extract info from the user's button choice, getEmail
grabs the email entity directly from the client's message using getFirstEntityWithRole
. This step is also shared — it is used in both the bookClass
stream and the getBookings
stream.
const getEmail = client.createStep({
satisfied() { return Boolean(client.getConversationState().email) },
extractInfo() {
// Get an e-mail provided by the user:
const email = client.getFirstEntityWithRole(client.getMessagePart(), 'email/email');
if (email) {
client.updateConversationState({ email: email.value });
}
},
prompt() {
client.addResponse('prompt/email');
// The getEmail step is used in multiple streams. If we're not in the
// default stream, set the expected next step.
if (client.getStreamName() !== 'bookClass') {
client.expect(client.getStreamName(), ['provide/email']);
}
client.done();
}
});
The prompt for this step sets an expectation if we're not in the default stream:
client.expect(client.getStreamName(), ['provide/email']);
Since getEmail
is shared between multiple streams, that serves as a hint that reply should provide an e-mail address and should be part of the current stream (eg. getBookings
).
Step 4: getName
Now we're ready to collect the user's name. Another benefit of using the Facebook Messenger connection is that it's aware of the user's Facebook profile, including their name. We can grab that with client.getMessagePart().sender
and automatically store it to the conversation state.
Just in case it's not available, our training data contains an intent to prompt the user for their name.
const getName = client.createStep({
satisfied() { return Boolean(
client.getConversationState().firstName &&
client.getConversationState().lastName)
},
extractInfo() {
// Check for sender name set from Facebook,
// or use the name provided by the user:
const sender = client.getMessagePart().sender;
const firstName = client.getFirstEntityWithRole(client.getMessagePart(), 'firstName') || sender.first_name;
const lastName = client.getFirstEntityWithRole(client.getMessagePart(), 'lastName') || sender.last_name;
if (firstName) {
client.updateConversationState({ firstName: firstName });
}
if (lastName) {
client.updateConversationState({ lastName: lastName });
}
},
prompt() {
client.addResponse('prompt/name')
client.done()
}
});
Step 5: bookAppointment
Finally we've got what we need to book an appointment:
- client name and e-mail address
- the class to book
- and the date and time.
This step won't contain an extractInfo()
step since we're not looking for anything else from the user and the satisfied()
method returns false since it's the last step. To book the class, the prompt()
method will call Acuity's post appointments
API with the client info we've been collecting in the conversation state:
prompt() {
// Get the whole conversation state:
const state = client.getConversationState();
// Book the class appointment using the gathered info
const options = {
method: 'POST',
body: {
appointmentTypeID: state.appointmentTypeID,
datetime: state.datetime,
firstName: state.firstName,
lastName: state.lastName,
email: state.email
}
};
acuity.request('/appointments', options, function (err, res, appointment) { ... });
After creating the booking, we can clear out the for this booking. This resets things for the user, allowing them to book another class or check their upcoming bookings:
// Clear out conversation state. This will reset our satisfied
// conditions and the user can schedule again.
client.updateConversationState({
appointmentTypeID: null,
datetime: null
});
Last, we'll send a response to the client letting them know the appointment is confirmed. The second argument for addResponse
is a map of entities for the intent to include in the message to the client. In this case, we'll want to echo the class name and time back to the client in the confirmation message:
// Send the confirmation message, with entities for the booking
client.addResponse('confirmation', {
type: appointment.type,
datetime: moment(appointment.datetime).format(dateFormat)
});
Here's the full listing for the bookAppointment
step:
const bookAppointment = client.createStep({
// This is the final step:
satisfied() { return false; },
prompt() {
// Get the whole conversation state:
const state = client.getConversationState();
// Book the class appointment using the gathered info
const options = {
method: 'POST',
body: {
appointmentTypeID: state.appointmentTypeID,
datetime: state.datetime,
firstName: state.firstName,
lastName: state.lastName,
email: state.email
}
};
acuity.request('/appointments', options, function (err, res, appointment) {
// Clear out conversation state. This will reset our satisfied
// conditions and the user can schedule again.
client.updateConversationState({
type: null,
appointmentTypeID: null,
datetime: null
});
// Send the confirmation message, with entities for the booking
client.addResponse('confirmation', {
type: appointment.type,
datetime: moment(appointment.datetime).format(dateFormat)
});
client.done();
});
}
});
With these steps implemented, you'll be able to interact with the booking bot through Facebook Messenger and schedule an appointment: https://www.messenger.com/
Checking Appointments
Once a couple appointments are scheduled, it's time to implement the getBookings
stream to check existing bookings. To keep things simple we'll look up a user's schedule by their e-mail address, reusing the getEmail
step. After we have a client's email, there's only one more step: getAppointments
to provide the user with their upcoming schedule.
We'll use Acuity's get appointments
API along with two parameters: email
and minDate
, set to now:
// Get upcoming appointments matching an e-mail address:
const state = client.getConversationState();
const options = {
qs: {
email: state.email,
minDate: moment().toISOString()
}
};
acuity.request('/appointments', options, function (err, res, appointments) { ... }
In the response, we'll check if there are any upcoming appointments and decide which response intent to send to the client:
// Decide which response intent to send: the upcoming schedule, or none:
if (appointments.length) {
// format upcoming schedule and response...
} else {
// If no appointments, send none intent:
client.addResponse('upcoming/none');
}
If there are any upcoming appointments matching the email address, we'll format them into a list for the response. First, sort them in chronological order then format them into a list with one session on each line. Similar to the confirmation step in the bookClass
stream, we'll provide that data for the response entities:
// Sort upcoming classes chronologically and format response
const classes = "\n" + appointments
.sort((a, b) => b.datetime < a.datetime )
.map(appointment =>
moment(appointment.datetime).format(dateFormat)+': '+appointment.type
).join(", \n")
// Send upcoming appointments intent, with entities:
client.addResponse('upcoming/appointments', {
'number/count': appointments.length,
'classes': classes
});
Just before calling client.done()
we'll clear the expected stream with client.expect(null)
. This resets the hint set in getEmail
, allowing the client to enter a different stream such as booking another class after checking their schedule. Here's the complete listing for our final step:
const getAppointments = client.createStep({
satisfied() { return false; },
prompt() {
// Get upcoming appointments matching an e-mail address:
const state = client.getConversationState();
const options = {
qs: {
email: state.email,
minDate: moment().toISOString()
}
};
acuity.request('/appointments', options, function (err, res, appointments) {
// Decide which response intent to send: the upcoming schedule, or none:
if (appointments.length) {
// Sort upcoming classes chronologically and format response
const classes = "\n" + appointments
.sort((a, b) => b.datetime < a.datetime )
.map(appointment =>
moment(appointment.datetime).format(dateFormat)+': '+appointment.type
).join(", \n")
// Send upcoming appointments intent, with entities:
client.addResponse('upcoming/appointments', {
'number/count': appointments.length,
'classes': classes
});
} else {
// If no appointments, send none intent:
client.addResponse('upcoming/none');
}
// Clear the expected stream after getting appointments:
client.expect(null);
client.done();
});
}
});
Now that that's implemented, we can check our schedule:
Conclusion
Well, this article didn't write itself! But Conversational AI has come a long way, and today is more dev-friendly than ever. Creating a bot that performs well for the two tasks I gave it — that's not bad for an afternoon!
You can find the full source code for this project on github. From there, there are two major improvements that can be made:
- Additional training data helping the model handle unexpected input and additional API cases such as no availability for the month.
- Expanding the bot into other common tasks, such as canceling or rescheduling a booking.
Plugging a conversational interface into existing APIs such as Acuity Scheduling is already a practical way to bring useful conversational functionality to other apps such as Facebook Messenger. From here, it'll only get better!
This post was sponsored via SyndicateAds.