-
Notifications
You must be signed in to change notification settings - Fork 129
Building a Plugin
Plugins provide the functionality of Abot. Whether you want to support a new conversation topic or drive the user to make a purchase, you'll probably want to build a plugin.
Before you start, you should search for existing plugins on
itsabot.org/plugins, as it's possible
someone in the community has already built what you're setting out to do. Add
whatever plugin you need there to your plugins.json
file and run abot install
to pull it in. Be sure to update any settings for the plugins at
http://localhost:4200/settings with Abot
running, as some plugins require API keys and other secrets.
If you can't find a relevant plugin on itsabot.org/plugins, then you'll want to build a plugin. This guide will walk you through the basic components of plugins and show you how to achieve advanced features like branching conversations easily.
Plugins consist of the following five parts:
- Plugin setup: Allows for communication between the plugin and Abot.
- Keyword handler: Triggers functions when specific Commands and Objects are used, like "checkout" or "see cart"
- State machine: Run the user through a multi-step process, like adding items to a cart, collecting credit card details, and purchasing the items
- Plugin events: Receive notifications from Abot when key events happen, useful for analytics and more.
- Training: Train your Abot on the "intent" of incoming sentences.
All plugins have a setup section, and most plugins will have both vocab handlers and a state machine.
Abot comes with scaffolding tools like those found in Rails. To create a new plugin, navigate to an appropriate place in your GOPATH (such as $GOPATH/src/github.com/YOUR_USERNAME), and run the following:
abot generate yourPlugin
cd yourPlugin
You'll need an account on itsabot.org to run this command, since this command generates a plugin that's ready to publish. Having an account on itsabot.org enables you to publish and train your plugin. You can create an account for free here: https://www.itsabot.org/signup
The generate command creates a new folder containing 3 files.
- plugin.json
- your_plugin_name.go
- your_plugin_name_test.go
The plugin.json is already filled out with all the information you need to
publish your plugin. The your_plugin_name.go
file contains your basic plugin,
and your_plugin_name_test.go
contains a test file. itsabot.org will run those
tests every time Abot is updated to ensure your plugin still works as intended.
Let's take a look at a basic plugin file (your_plugin_name.go) will look. Plugins all share the basic structure below.
package {{pluginName}}
import (
"log"
"github.com/itsabot/shared/plugin"
)
var p *dt.Plugin
func init() {
var err error
p, err = plugin.New("github.com/{{yourUsername}}/{{pluginName}}")
if err != nil {
log.Fatalln("building", err)
}
// Abot should route messages to this plugin that contain any combination
// of the below words. The stems of the words below are used, so you don't
// need to include duplicates (e.g. there's no need to include both "stock"
// and "stocks"). Everything will be lowercased as well, so there's no
// difference between "ETF" and "etf".
plugin.SetKeywords(p,
dt.KeywordHandler{
Fn: kwGetTemp,
Trigger: &dt.StructuredInput{
Commands: []string{"what", "show", "how"},
Objects: []string{"weather", "outside", "temperature"},
},
},
)
if err = plugin.Register(p); err != nil {
p.Log.Fatal(err)
}
}
// Whenever Abot receives any of the getTemp Trigger Commands and Objects, it
// will route to this function.
func kwGetTemp(in *dt.Msg) (resp string) {
city, err := getCity(in)
if err == language.ErrNotFound {
return ""
}
if err != nil {
p.Log.Info("failed to getCity.", err)
return ""
}
p.SetMemory(in, "city", city)
return getWeather(city)
}
As you modify your plugin, be sure to run go test
to ensure that the tests
continue to pass. That will become important after publishing, so itsabot.org
can notify you if your plugin breaks.
The keyword handler allows you to respond to specific Commands and Objects in user messages, so they're useful when you want to immediately perform some function as a response to a user's message. Keyword handlers alone allow you to replicate a great deal of a digital assistant's functionality, like Siri.
For example, let's have Abot check stocks for us. Given the triggers set above, Abot will call this plugin when a user says, "show me my portfolio" or "buy an ETF", but not "buy", "portfolio", or "show" by themselves. For a trigger to occur, it always requires a combination of Commands and Objects.
With that in mind, let's add a few keyword handlers to our plugin. We'll use the same plugin setup as before, showing only the lines that changed and the lines immediately before them to provide some context as to where these new lines go.
func init() {
var err error
p, err = plugin.New(...)
if err != nil {
log.Fatalln("failed to build plugin.", err)
}
// Add keyword handlers to the plugin
plugin.SetKeywords(p,
dt.KeywordHandler{
Fn: findStocks,
Trigger: &dt.StructuredInput{
Commands: []string{"find"},
Objects: []string{"stocks", "etf"},
},
},
dt.KeywordHandler{
Fn: showPortfolio,
Trigger: &dt.StructuredInput{
Commands: []string{"show"},
Objects: []string{"stocks", "portfolio"},
},
},
)
...
}
// This function will run whenever a user says "find stocks" or "find ETFs".
// In this case we expect a ticker symbol, e.g. "GOOG" or "AAPL", but the below
// implementation could easily be extended to remove that requirement.
func findStocks(in *dt.Msg) (resp string) {
// Perform some lookup. We'll leave the implementation of this as an
// exercise to reader.
var s *stock.Stock
for _, obj := range in.StructuredInput.Objects {
// Ticker symbols can be at most 5 letters long
if len(obj) > 5 {
continue
}
s = stock.Get(obj)
}
// In the case of no stock found, return an error message.
// If you return an empty string, Abot will respond to the
// user with confusion, like "I'm not sure what you mean."
if s == nil {
return "I couldn't find any stocks like that."
}
return s.Name + " is trading at " + s.Price
}
// This function will run whenever a user says "show stocks" or "show
// portfolio". Real plugins should use more comprehensive triggers that contain
// more words.
func showPortfolio(in dt.Msg) string {
// Look up stocks here, again left as exercise to the reader
s := stock.GetPortfolioForUser(in.User)
return fmt.Sprintf("Your portfolio is doing %s, having moved %.2f today",
s.Status, s.Movement)
}
Plugins use state machines to run users through some step-by-step process from beginning to end. For example, we might use a state machine to allow for buying stocks, where the states would be (simplified):
- Ask the user which stock they'd like to purchase
- Confirm how many shares they'd like to buy and at what price
- Confirm you have the user's bank account or debit card on file.
- Make the purchase, charging the card on file and notifying the user.
Doing this with a simple switch
statement would be fine, but as plugins and
the states they control grow, a switch statement becomes unwieldy quickly. The
state machine API described below is designed after our first-hand experience
trying other solutions. We've built the easiest state machine we were able to
for this specific problem domain.
Setting up a state machine involves adding several lines of code. It's important to note that a state machine and keyword handlers can exist side-by-side (and often do) within a plugin, but for brevity we will show a plugin that's been set up with an added state machine but no keyword handler.
func init() {
var err error
p, err = plugin.New(...)
if err != nil {
log.Fatalln("failed to build plugin.", err)
}
// Keyword handlers like above
...
plugin.SetStates(p, [][]dt.State{[]dt.State{
{
// OnEntry is only run once when the state is first entered. It's
// where you can prompt the user with a question.
OnEntry: func(in *dt.Msg) (resp string) {
return "Which stock would you like to purchase?"
},
// OnInput runs every time the user messages this Abot until
// Complete returns true.
OnInput: func(in *dt.Msg) {
var s *stock.Stock
for _, obj := range in.StructuredInput.Objects {
if len(obj) > 5 {
continue
}
s = stock.Get(obj)
if s != nil {
p.SetMemory(in, "stock_selection", s)
}
}
},
// Complete checks if the state is complete and thus if the state
// machine is able to continue to the next state. If Complete
// returns false, you have the option of returning a user-facing
// string that informs the user what went wrong or what additional
// information or clarification the OnInput function needs. If you
// Complete returns false with an empty reason for failure string,
// Abot will reply to the user with confusion, like "I'm not sure
// what you mean."
Complete: func(in *dt.Msg) (complete bool, reasonForFailure string) {
if (sm.HasMemory(in, "stock_selection")) {
return true, ""
}
return false, "I couldn't find a stock matching your description. What's the 2-4 letter ticker symbol?"
},
},
...
)
// SetOnReset can be used to clear out any memories from Abot between runs.
// If you want to persist a memory for this plugin, like a brand preference
// or a shipping address, then do not include it in this function. Since
// the stock the user selected, the share count, and its price are only
// relevant for this run, we'll clear out those memories here.
p.SM.SetOnReset(func(in *dt.Msg) {
p.DeleteMemory(in, "stockSelection")
p.DeleteMemory(in, "shareCount")
p.DeleteMemory(in, "perSharePrice")
})
}
You saw a new API above called dt.Memory. This is a core component of Abot's
library because it enables Abot to recall past transactions and selections
within and across plugins. It's a global key-value store, and it's shared
across all plugins for each user. Therefore if one plugin sets a
shipping_address
for a particular user, another plugin can just confirm that
the shipping_address
is still valid. Therefore plugins can pass messages to
one another, enabling a Unix-like "piping" of output from one command to the
next from smaller, composable, standalone pieces. For that reason, no sensitive
information like credit card details or passwords should ever be stored in
memories.
Sometimes you only need to ask a user something once, not everytime they start using a plugin. For example, if I needed to know a user's name, I should only need to ask once. We can achieve this using SkipIfComplete like so:
// Within your states
{
SkipIfComplete: true,
OnEntry: func(in *dt.Msg) string {
return "What's your name?"
},
OnInput: func(in *dt.Msg) {
name := extractName(in) // Build this function.
sm.SetMemory(in, "name", name)
},
Complete: func(in *dt.Msg) (bool, string) {
return sm.HasMemory(in, "name"), ""
},
}
If SkipIfComplete is set to true, the state machine will first check if the state is complete (in the above case, if Abot has any memory of "name"). If this state is complete, then the state is skipped entirely. This is a simple feature that enables a very powerful result. Using SkipIfComplete, you can process complex order information like ordering a pizza, and your state machine will only ask for the information if it's missing.
Often you'll want to perform some pretty common task in Abot, like requesting a user's shipping address or payment information. Tasks make common requests easy. Simply add one of the predefined request types to your states, and Abot will request the information from the user without any further work on your part.
Let's take a look at how it works.
plugin.SetStates(p, [][]dt.State{
[]dt.State{
{
// Your states here
},
}
task.New(sm, task.RequestAddress, "shipping_address"),
[]dt.State{
{
// Your states here
},
}
)
Note that the label we give our task (here "shipping_address") allows us to jump to that task as described in the section on jumping to specific states. Finally you can see by the documentation for task.New() that it's just returning another predefined []dt.State{}, one designed to do whatever task needs doing.
One special kind of task is Iterate
, which enables you to easily offer the user a
selection of options, such as selecting a restaurant, seats for a game, or a
hotel. To use Iterate, try the following:
plugin.SetStates(p, [][]dt.State{
[]dt.State{
{
// Your states here, which populate data in memory
// under the key "restaurantSearchResults"
},
}
task.Iterate(p, "iterate-label", task.OptsIterate{
// IterableMemKey is the key in memory that holds the []string
// to be presented to the user one by one. For example, this
// might be a restaurant's name, a seat's row/section number,
// or key details about a hotel room like the hotel's name, how
// many beds, and the per night price.
IterableMemKey: "restaurantSearchResults",
// ResultMemKeyIdx is the key in memory that holds the index of
// the selected item in the []string.
ResultMemKeyIdx: "selectedRestaurantIdx",
}),
[]dt.State{
{
OnEntry: func(in *dt.Msg) string {
var res []string
mem := p.GetMemory(in, "restaurantSearchResults")
err := json.Unmarshal(mem.Val, &res)
if err != nil {
p.Log.Info("failed to unmarshal results.", err)
return ""
}
idx := p.GetMemory(in, "selectedRestaurantIdx").Int64()
return "Great! You selected " + res[idx]
},
OnInput: func(in *dt.Msg) {},
Complete: func(in *dt.Msg) (bool, string) {
// Notice that we keep the user in this state.
// This allows the user to continue asking
// questions without memory being reset. We do
// this because when the user selects a
// restaurant, they usually aren't "done,"
// since they'll now need the phone number,
// etc.
return false, ""
},
},
},
)
p.SM.SetOnReset(func(in *dt.Msg) {
task.ResetIterate(p, in)
p.DeleteMemory(in, "restaurantSearchResults")
p.DeleteMemory(in, "selectedRestaurantIdx")
})
Have a great idea for a task? Suggest we build it, or contribute one yourself!
Branching conversations are useful when you're trying to take the user down one of multiple conversation paths. It's like a "choose your own adventure" book, where a user's answer to an early question may lead to one of multiple paths the user experiences.
For example, if we wanted to build a plugin to allow purchasing various categories of items, we might want to ask different questions if the customer wants to buy a chair or a sofa, flowers or wine, a car or a motorcycle. Doing this involves branching conversations, and Abot's state machine API makes it easy to do that. Let's take a look at an example:
func init() {
...
plugin.SetStates(p, start)
// Notice that we define the SetBranches function on the plugin itself.
// The signature of the function must match the following for the
// plugin to compile.
p.SetBranches = func(in *dt.Msg) [][]dt.State {
state := sm.GetMemory(in, "pickedProduct").String()
switch state {
case "":
return start
case "chair":
return buyChair
case "sofa":
return buySofa
}
p.Log.Info("did not recognize state", state)
return start
}
}
// start is our collection of states that are independent of the others.
var start = [][]dt.State{[]dt.State{
{
OnEntry: func(in *dt.Msg) string {
return "Are you looking to buy a chair or a sofa?"
},
OnInput: func(in *dt.Msg) {
chair := extractChair(in) // Build this function
if chair != nil {
sm.SetMemory(in, "pickedProduct", "sofa")
return
}
sofa := extractSofa(in) // Build this function
if sofa != nil {
sm.SetMemory(in, "pickedProduct", "chair")
}
},
Complete: func(in *dt.Msg) (bool, string) {
return sm.HasMemory(in, "pickedProduct"), ""
},
},
}
// First we define our two branches. buyChair is branch one.
var buyChair = [][]dt.State{[]dt.State{
{
OnEntry: func(in *dt.Msg) string {
return "Ok. What kind of chair are you looking for? You "
},
OnInput: func(in *dt.Msg) {
chairType := extractChairType(in) // Build this function
if chairType != nil {
sm.SetMemory(in, "chair_type", "")
}
},
Complete: func(in *dt.Msg) (bool, string) {
return sm.HasMemory(in, "chair_type"), ""
},
},
// more chair-specific tasks here
}
// And buySofa is branch two.
var buySofa = [][]dt.State{[]dt.State{
{
OnEntry: func(in *dt.Msg) string {
return "Are you looking for a sectional?"
},
OnInput: func(in *dt.Msg) {
sofaType := extractSofaType(in) // Build this function
if sofaType != nil {
sm.SetMemory(in, "sofa_type", "")
}
},
Complete: func(in *dt.Msg) (bool, string) {
return sm.HasMemory(in, "sofa_type"), ""
},
},
// more sofa-specific tasks here
}
It may not make sense to send the user through required step after step if they
can jump to the end. For instance, a user may want to skip product selection
and jump straight to checkout. To do this, we use p.SM.SetState()
.
SetState() will jump as closely to the desired state as possible--it'll stop when it reaches any state between the current state and the desired state where Complete != true. Thus you can handle a jump to checkout while still being certain that the user has items in their cart.
Let's take a look at an example where we allow a user to jump from product selection to checkout if and only if that user has items in his or her cart.
{
OnEntry: func(in *dt.Msg) string {
return "Which car interests you, Mr. Bond?"
},
OnInput: func(in *dt.Msg) {
car := extractCar(in) // Write this function
if car != nil {
sm.SetMemory(in, "selectedCar", car)
}
},
Complete: func(in *dt.Msg) (bool, string) {
return sm.HasMemory(in, "selectedCar")
},
},
{
// We add a label to this state, so we can jump directly to it.
Label: "checkout",
OnEntry: func(in *dt.Msg) string {
return "Ok, let's checkout. I'll just need to confirm some information."
},
OnInput: func(in *dt.Msg) {
// Authorize user and confirm payment
if () {
...
}
},
Complete: func(in *dt.Msg) (bool, string) {
return sm.HasMemory(in, "purchaseComplete")
},
},
Then we'll modify our keyword handler to support a checkout trigger.
plugin.SetKeywords(p,
dt.KeywordHandler{
Fn: kwCheckout,
Trigger: &dt.StructuredInput{
// Here we duplicate many of the Commands as Objects. Abot
// frequently categorizes words as belonging to both categories,
// and this duplication allows us to listen for individual words,
// rather than requiring separate Command+Object matches.
Commands: []string{"checkout", "check out", "done", "ready", "ship"},
Objects: []string{"checkout", "done", "ready", "ship"},
},
},
)
Then we'll enable our user to jump to the labeled state as follows:
func kwCheckout(in *dt.Msg) string {
return p.SM.SetState(in, "checkout")
}
Notice that SetState returns a string. This enables us to immediately return the correct state machine response given the user's completed states and where we'd like for the user to be.
Finally we'll update our p.SM.Reset function wherever we build our state machine. We need to be sure we're starting from a clean slate, so wipe any memories before the user leaves like so:
p.SM.SetOnReset(func(in *dt.Msg) {
p.DeleteMemory(in, "selectedCar")
p.DeleteMemory(in, "purchaseComplete")
})
Using a state machine with a keyword handler is easy. Simply define both before registering your plugin. Abot will attempt your keyword handlers first. If none return a string with length > 0, Abot will try your state machine. In practice this is a natural feeling behavior which allows for user commands with keywords like "check out" to be handled before hitting your state machine.
Plugins can register to listen for events in Abot's core, such as a new message being received, processed, or sent. By listening to events, plugins can even modify messages on the fly before a message is routed to the plugin that will ultimately handle the response. Think of it like Middleware for a router. Abot will notify all listening plugins when an event occurs in its core, regardless of which plugin is ultimately triggered. This enables plugins to support features like analytics.
To listen for plugin events, modify your plugin as follows:
p, err = plugin.New("your/plugin/path")
if err != nil {
log.Fatal(err)
}
p.Events.PostReceive = func(cmd *string) {
// We can see each user's message from any plugin.
p.Log.Info("post receive", *cmd)
// We can even modify the user's message.
*cmd = fmt.Sprintf("Hi Abot! %s", *cmd)
}
p.Events.PostProcessing = func(in *Msg) {
p.Log.Infof("post processing: %+v\n", in)
}
p.Events.PostResponse = func(in *Msg, resp *string) {
p.Log.Infof("post response input: %+v\n", in)
p.Log.Infof("post response: %s\n", *resp)
// And we can modify plugin output prior to sending it to users as well
*resp = fmt.Sprintf("Sure! %s", *resp)
}
You can find a full guide on how to train plugins on intent here.
Plugins must have unique names which are enforced by itsabot.org. Names and
other plugin customizations are defined in the plugin.json
spec. At minimum,
plugin.json is required to have a unique Name
as well as a Maintainer
key containing a user's email.
Plugins always respond to Abot with a string. If a plugin returns a string with a length > 0, that exact response will be returned to the user.
Notice in the APIs that there's no way to bubble an error up to the main Abot function. Any errors encountered by a plugin must be logged and handled internally by that function, and the plugin should reply to the user with a humanized explaination of what went wrong. It's important to provide the user actionable feedback as to how the problem can be corrected whenever possible, and every effort should be made to prevent cryptic errors from being presented to the user.
Do: It looks like I couldn't reach the weather service. We should try again later.
Don't: Err 404. Page not found.
© Copyright 2015-2016 Abot, Inc.