Skip to content

UserContextStorage

Oklahomer edited this page Dec 16, 2017 · 7 revisions

The idea and its implementation of "user's conversational context" is go-sarah's signature feature. While typical bot implementation is somewhat "stateless" and hence user-bot interaction does not consider previous state, Sarah let sarah.Command tell what action to follow on next user input and stash current state temporary. On next user input, the message is passed to the function stated on previous command execution, and resume previous unfinished command. User may input special command, ".abort" by default, to forcefully get out of current state.

UserContextStorage

To use this feature, your bot must be initialized with sarah.UserContextStorage implementation. This is where current user state is stored. Currently two kinds of storages are supported. One that stores state in process memory; another that stores in external storage. That will be covered in later section.

Below example shows how to setup sarah.Bot with default storage.

// Setup slack adapter.
adapter, err := slack.NewAdapter(config.Slack)
if err != nil {
	panic(fmt.Errorf("faileld to setup Slack Adapter: %s", err.Error()))
}

// Use default implementation of UserContextStorage, which stores state in process memory.
storage := sarah.NewUserContextStorage(config.UserContextStorage)

// Setup Bot with slack adapter and default storage.
bot, err := sarah.NewBot(adapter, sarah.BotWithStorage(storage))
if err != nil {
	panic(fmt.Errorf("faileld to setup Slack Bot: %s", err.Error()))
}

Example Commands

Ler User Input Required Arguments Step by Step

This example command TodoCommand let user supply required arguments step by step for better user experience.

Simplified example code is as below. In this example sarah.Command is implemented by TodoCommand struct. Complete runnable code is located at ./example/plugins/todo.

To see how to create one with sarah.CommandPropsBuilder, see later example.

type Schedule struct {
	time        time.Time
	title       string
	description string
	userKey     string
}

var matchPattern = regexp.MustCompile(`^\.todo`)

type TodoCommand struct {
	db *dataBase
}

// TodoCommand implements sarah.Command
var _ sarah.Command = (*TodoCommand)(nil)

func (todo *TodoCommand) Identifier() string {
	return "todo"
}

func (todo *TodoCommand) Execute(_ context.Context, input sarah.Input) (*sarah.CommandResponse, error) {
	// This is called when user input ".todo blah blah"
	task := sarah.StripMessage(matchPattern, input.Message())
	schedule := &Schedule{
		description: task,
	}

	// Instead of returning plain text with slack.NewStringResponse, use slack.NewStringResponseWithNext to indicate what function to execute on next user input
	return slack.NewStringResponseWithNext("Input due date time. YYYY-MM-DD HH:MM", func(c context.Context, i sarah.Input) (*sarah.CommandResponse, error) {
		fmt.Println(i.Message())
		return todo.inputTime(c, i, schedule)
	}), nil
}

func (todo *TodoCommand) InputExample() string {
	return ".todo go to school"
}

func (todo *TodoCommand) Match(input sarah.Input) bool {
	return matchPattern.Copy().MatchString(input.Message())
}

func (todo *TodoCommand) inputTime(_ context.Context, input sarah.Input, schedule *Schedule) (*sarah.CommandResponse, error) {
	// See if given input includes valid time format
	t, err := time.Parse("2006-01-02 15:04", strings.TrimSpace(input.Message()))
	if err != nil {
		// When invalid format is given, tell user and let input again. This very same method is to be called on next input.
		return slack.NewStringResponseWithNext("Use YYYY-MM-DD HH:MM format.", func(c context.Context, i sarah.Input) (*sarah.CommandResponse, error) {
			return todo.inputTime(c, i, schedule)
		}), nil
	}

	// If due is successfully given, stash this input and let user go to next step.
	schedule.time = t
	return slack.NewStringResponseWithNext("Please input description.", func(c context.Context, i sarah.Input) (*sarah.CommandResponse, error) {
		return todo.inputDescription(c, i, schedule)
	}), nil
}

func (todo *TodoCommand) inputDescription(_ context.Context, input sarah.Input, schedule *Schedule) (*sarah.CommandResponse, error) {
	// At this point all arguments are given, so store them into persistent storage.
	schedule.description = input.Message()
	todo.db.StoreSchedule(schedule)

	// Then return plain string message for confirmation.
	// This time, slack.NewStringResponse is used instead of slack.NewStringResponseWithNext.
	msg := fmt.Sprintf(`Saved. Title: %s. Description: %s Due: %s`, schedule.title, schedule.description, schedule.time.String())
	return slack.NewStringResponse(msg), nil
}

// dataBase stores data to persistent storage
type dataBase struct {
}

func (db *dataBase) StoreSchedule(schedule *Schedule) {
	// Create record
}

Game that Checks User Input Until Certain Condition is Met

This example game command generate a random value at initial input, and let user input numbers until given number is equal to the initially generated value.

Below example uses sarah.CommandPropsBuilder to setup non contradicting set of arguments to build sarah.Command on the fly. See CommandPropsBuilder for detailed usage.

var guessProps = sarah.NewCommandPropsBuilder().
	BotType(slack.SLACK).
	Identifier("guess").
	InputExample(".guess").
	MatchFunc(func(input sarah.Input) bool {
		return strings.HasPrefix(strings.TrimSpace(input.Message()), ".guess")
	}).
	Func(func(ctx context.Context, input sarah.Input) (*sarah.CommandResponse, error) {
		// Generate answer value at the very beginning.
		rand.Seed(time.Now().UnixNano())
		answer := rand.Intn(100)

		// Let user guess the right answer.
		return slack.NewStringResponseWithNext("Input number.", func(c context.Context, i sarah.Input) (*sarah.CommandResponse, error) {
			return guessFunc(c, i, answer)
		}), nil
	}).
	MustBuild()

func guessFunc(_ context.Context, input sarah.Input, answer int) (*sarah.CommandResponse, error) {
	// For handiness, create a function that recursively calls guessFunc until user input right answer.
	retry := func(c context.Context, i sarah.Input) (*sarah.CommandResponse, error) {
		return guessFunc(c, i, answer)
	}

	// See if user inputs valid number.
	guess, err := strconv.Atoi(strings.TrimSpace(input.Message()))
	if err != nil {
		return slack.NewStringResponseWithNext("Invalid input format.", retry), nil
	}

	// If guess is right, tell user and finish current user context.
	// Otherwise let user input next guess with bit of a hint.
	if guess == answer {
		return slack.NewStringResponse("Correct!"), nil
	} else if guess > answer {
		return slack.NewStringResponseWithNext("Smaller!", retry), nil
	} else {
		return slack.NewStringResponseWithNext("Bigger!", retry), nil
	}
}