/go-sarah

Simple yet customizable bot framework written in Go.

Primary LanguageGoMIT LicenseMIT

Go Reference Go Report Card CI Status Coverage Status Maintainability Join the chat at https://gitter.im/go-sarah-dev/community

Introduction

Sarah is a general-purpose bot framework named after the author's firstborn daughter.

This comes with a unique feature called "stateful command" as well as some basic features such as commands and scheduled tasks. In addition to those fundamental features, this project provides rich life cycle management including live configuration update, customizable alerting mechanism, automated command/task (re)building, and panic-proofed concurrent command/task execution.

Such features are achieved with a composition of fine-grained components. Each component has its own interface and a default implementation, so developers are free to customize their bot experience by replacing the default implementation for a particular component with their own implementation. Thanks to such segmentalized lifecycle management architecture, the adapter component to interact with each chat service has fewer responsibilities compared to other bot frameworks; An adapter developer may focus on implementing the protocol to interact with the corresponding chat service. To take a look at those components and their relations, see Components.

IMPORTANT NOTICE

v4 Release

This is the fourth major version of go-sarah, which involves some architectural changes:

  • sarah.NewBot now returns a single value: sarah.Bot
  • Utility packages including logger, retry, and worker are now hosted by github.com/oklahomer/go-kasumi

v3 Release

This is the third major version of go-sarah, which introduces the Slack adapter's improvement to support both RTM and Events API. Breaking interface changes for the Slack adapter was inevitable and that is the sole reason for this major version up. Other than that, this does not include any breaking change. See Migrating from v2.x to v3.x for details.

v2 Release

The second major version introduced some breaking changes to go-sarah. This version still supports and maintains all functionalities, while better interfaces for easier integration are introduced. See Migrating from v1.x to v2.x to migrate from the older version.

Supported Chat Services/Protocols

Although a developer may implement sarah.Adapter to integrate with the desired chat service, some adapters are provided as reference implementations:

At a Glance

General Command Execution

hello world

Above is a general use of go-sarah. Registered commands are checked against the user input and the matching one is executed; when a user inputs ".hello," hello command is executed and a message "Hello, 世界" is returned.

Stateful Command Execution

The below image depicts how a command with a user's conversational context works. The idea and implementation of "user's conversational context" is go-sarah's signature feature that makes a bot command "state-aware."

The above example is a good way to let a user input a series of arguments in a conversational manner. Below is another example that uses a stateful command to entertain the user.

Example Code

Following is the minimal code that implements such general command and stateful command introduced above. In this example, two ways to implement sarah.Command are shown. One simply implements sarah.Command interface; while another uses sarah.CommandPropsBuilder for lazy construction. Detailed benefits of using sarah.CommandPropsBuilder and sarah.CommandProps are described at its wiki page, CommandPropsBuilder.

For more practical examples, see ./examples.

package main

import (
	"context"
	"fmt"
	"github.com/oklahomer/go-sarah/v4"
	"github.com/oklahomer/go-sarah/v4/slack"
	
	"os"
	"os/signal"
	"syscall"
	
	// Below packages register commands in their init().
	// Importing with a blank identifier will do the magic.
	_ "guess"
	_ "hello"
)

func main() {
	// Set up Slack adapter
	setupSlack()
	
	// Prepare Sarah's core context.
	ctx, cancel := context.WithCancel(context.Background())

	// Run
	config := sarah.NewConfig()
	err := sarah.Run(ctx, config)
	if err != nil {
		panic(fmt.Errorf("failed to run: %s", err.Error()))
	}
	
	// Stop when a signal is sent.
	c := make(chan os.Signal, 1)
   	signal.Notify(c, syscall.SIGTERM)
   	select {
   	case <-c:
   		cancel()
   
   	}
}

func setupSlack() {
	// Set up slack adapter.
	slackConfig := slack.NewConfig()
	slackConfig.Token = "REPLACE THIS"
	adapter, err := slack.NewAdapter(slackConfig, slack.WithRTMPayloadHandler(slack.DefaultRTMPayloadHandler))
	if err != nil {
		panic(fmt.Errorf("faileld to setup Slack Adapter: %s", err.Error()))
	}

	// Set up an optional storage so conversational contexts can be stored.
	cacheConfig := sarah.NewCacheConfig()
	storage := sarah.NewUserContextStorage(cacheConfig)

	// Set up a bot with Slack adapter and a default storage.
	bot := sarah.NewBot(adapter, sarah.BotWithStorage(storage))
	
	sarah.RegisterBot(bot)
}

package guess

import (
	"context"
	"github.com/oklahomer/go-sarah/v4"
	"github.com/oklahomer/go-sarah/v4/slack"
	"math/rand"
	"strconv"
	"strings"
	"time"
)

func init() {
	sarah.RegisterCommandProps(props)
}

var props = sarah.NewCommandPropsBuilder().
	BotType(slack.SLACK).
	Identifier("guess").
	Instruction("Input .guess to start a game.").
	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 an answer value at the very beginning.
		rand.Seed(time.Now().UnixNano())
		answer := rand.Intn(10)

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

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

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

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

package hello

import (
	"context"
	"github.com/oklahomer/go-sarah/v4"
	"github.com/oklahomer/go-sarah/v4/slack"
	"strings"
)

func init() {
    sarah.RegisterCommand(slack.SLACK, &command{})	
}

type command struct {
}

var _ sarah.Command = (*command)(nil)

func (hello *command) Identifier() string {
	return "hello"
}

func (hello *command) Execute(_ context.Context, i sarah.Input) (*sarah.CommandResponse, error) {
	return slack.NewResponse(i, "Hello!")
}

func (hello *command) Instruction(input *sarah.HelpInput) string {
	if 12 < input.SentAt().Hour() {
		// This command is only active in the morning.
		// Do not show instruction in the afternoon.
		return ""
	}
	return "Input .hello to greet"
}

func (hello *command) Match(input sarah.Input) bool {
	return strings.TrimSpace(input.Message()) == ".hello"
}

Supported Golang Versions

Official Release Policy says "each major Go release is supported until there are two newer major releases." Following this policy would help this project enjoy the improvements introduced in the later versions. However, not all projects can immediately switch to a newer environment. Migration could especially be difficult when this project cuts off the older version's support right after a new major Go release.

As a transition period, this project includes support for one older version than the Go project does. Such a version is guaranteed to be listed in .travis.ci. In other words, new features/interfaces introduced in 1.10 can be used in this project only after 1.12 is out.

Further Readings