├── examples ├── gobot_example.gif ├── main.go └── food │ └── food.go ├── machine ├── machine_test.go ├── machine.go ├── state_test.go └── state.go ├── go.mod ├── interactive_api_handler.go ├── gobot.go ├── event_api_handler.go ├── go.sum └── README.md /examples/gobot_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ogidow/gobot/HEAD/examples/gobot_example.gif -------------------------------------------------------------------------------- /machine/machine_test.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAddState(t *testing.T) { 10 | target := NewMachine("test") 11 | 12 | target.AddState("fuga", func(s *State) { 13 | s.Text("hoge") 14 | s.InitialState() 15 | }) 16 | 17 | assert.NotNil(t, target.states["fuga"]) 18 | assert.NotNil(t, target.Current) 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ogidow/gobot 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.4.0 // indirect 7 | github.com/lusis/go-slackbot v0.0.0-20180109053408-401027ccfef5 // indirect 8 | github.com/lusis/slack-test v0.0.0-20180109053238-3c758769bfa6 // indirect 9 | github.com/pkg/errors v0.8.1 // indirect 10 | github.com/slack-go/slack v0.6.3 11 | github.com/stretchr/testify v1.3.0 12 | ) 13 | -------------------------------------------------------------------------------- /interactive_api_handler.go: -------------------------------------------------------------------------------- 1 | package gobot 2 | 3 | import ( 4 | "net/http" 5 | "encoding/json" 6 | 7 | "github.com/slack-go/slack" 8 | ) 9 | 10 | type interactiveApiHandler struct { 11 | bot *Gobot 12 | } 13 | 14 | func (g *Gobot) NewInteractiveApiHandler() interactiveApiHandler { 15 | return interactiveApiHandler{g} 16 | } 17 | 18 | func (h interactiveApiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 19 | if r.Method != "POST" { 20 | w.WriteHeader(http.StatusBadRequest) 21 | return 22 | } 23 | r.ParseForm() 24 | payload := r.PostForm.Get("payload") 25 | var callback slack.InteractionCallback 26 | json.Unmarshal([]byte(payload), &callback) 27 | 28 | h.bot.HandleAndResponse(w, callback) 29 | } 30 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "net/http" 6 | 7 | "github.com/ogidow/gobot" 8 | "github.com/ogidow/gobot/examples/food" 9 | ) 10 | 11 | func main() { 12 | bot := gobot.NewGobot() 13 | bot.AddMachine(food.NewMachine()) 14 | 15 | slackVerificationToken := "YOURE_SLACK_VERIFICATION_TOKEN" 16 | SlackAccessToken := "YOURE_SLACK_ACCESS_TOKEN" 17 | 18 | eventHandler := bot.NewEventApiHandler(slackVerificationToken, "what do you do?", SlackAccessToken) 19 | http.Handle("/path/to/your_event_api", eventHandler) 20 | 21 | interactiveHandler := bot.NewInteractiveApiHandler() 22 | http.Handle("/path/to/your_interactive_messages_api", interactiveHandler) 23 | http.ListenAndServe(":" + os.Getenv("PORT"), nil) 24 | } 25 | -------------------------------------------------------------------------------- /machine/machine.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "github.com/slack-go/slack" 5 | ) 6 | 7 | type Machine struct { 8 | Name string 9 | states map[string]*State 10 | Current *State 11 | } 12 | 13 | func NewMachine(name string) *Machine{ 14 | states := map[string]*State{} 15 | return &Machine{Name: name, states: states} 16 | } 17 | 18 | func (m *Machine) AddState(stateName string, f func(s *State)) { 19 | state := NewState(stateName) 20 | f(state) 21 | m.states[stateName] = state 22 | 23 | if state.initial { 24 | m.Current = state 25 | } 26 | } 27 | 28 | func(m *Machine) Event(name string, callback slack.InteractionCallback) { 29 | ev := m.Current.Events[name] 30 | ev.Process(callback) 31 | m.Current = m.states[ev.To] 32 | } 33 | 34 | func (m *Machine) Attachment() slack.Attachment{ 35 | return *m.Current.attachment 36 | } 37 | 38 | func(m *Machine) BuildAttachment(callback slack.InteractionCallback){ 39 | m.Current.clearAttachment() 40 | m.Current.BuildAttachmentFunc(callback) 41 | } 42 | -------------------------------------------------------------------------------- /gobot.go: -------------------------------------------------------------------------------- 1 | package gobot 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/ogidow/gobot/machine" 8 | "github.com/slack-go/slack" 9 | ) 10 | 11 | type Gobot struct { 12 | machines map[string]machine.Machine 13 | states map[string]*machine.Machine 14 | } 15 | 16 | func NewGobot() *Gobot { 17 | machines := map[string]machine.Machine{} 18 | states := map[string]*machine.Machine{} 19 | return &Gobot{machines, states} 20 | } 21 | 22 | func (g *Gobot) AddMachine(machine machine.Machine) { 23 | g.machines[machine.Name] = machine 24 | } 25 | 26 | func (g *Gobot) HandleAndResponse(w http.ResponseWriter, callbackEvent slack.InteractionCallback) { 27 | action := callbackEvent.ActionCallback.AttachmentActions[0].Name 28 | messageTs := callbackEvent.MessageTs 29 | machine := g.states[messageTs] 30 | if machine == nil { 31 | machineName := callbackEvent.ActionCallback.AttachmentActions[0].SelectedOptions[0].Value 32 | tmpMachine := g.machines[machineName] 33 | g.states[messageTs] = &tmpMachine 34 | machine = &tmpMachine 35 | } else { 36 | machine.Event(action, callbackEvent) 37 | } 38 | 39 | machine.BuildAttachment(callbackEvent) 40 | 41 | message := slack.Msg{ 42 | ReplaceOriginal: true, 43 | Attachments: []slack.Attachment{machine.Attachment()}, 44 | } 45 | 46 | if machine.Current.End { 47 | delete(g.states, messageTs) 48 | } 49 | 50 | w.Header().Add("Content-type", "application/json") 51 | json.NewEncoder(w).Encode(&message) 52 | } 53 | 54 | func (g *Gobot) GetMachines() []slack.AttachmentActionOption { 55 | var machines []slack.AttachmentActionOption 56 | 57 | for name := range g.machines { 58 | machines = append(machines, slack.AttachmentActionOption{Text: name, Value: name}) 59 | } 60 | 61 | return machines 62 | } 63 | -------------------------------------------------------------------------------- /machine/state_test.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/slack-go/slack" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestInitialState(t *testing.T) { 11 | target := NewState("test") 12 | target.InitialState() 13 | assert.Equal(t, true, target.initial) 14 | } 15 | 16 | func TestEndState(t *testing.T) { 17 | target := NewState("test") 18 | target.EndState() 19 | assert.Equal(t, true, target.End) 20 | } 21 | 22 | func TestColor(t *testing.T) { 23 | target := NewState("test") 24 | target.Color("#ffffff") 25 | assert.Equal(t, "#ffffff", target.attachment.Color) 26 | } 27 | 28 | func TestText(t *testing.T) { 29 | target := NewState("test") 30 | target.Text("Title Text") 31 | assert.Equal(t, "Title Text", target.attachment.Text) 32 | } 33 | 34 | func TestButton(t *testing.T) { 35 | target := NewState("test") 36 | target.Button("ok", "accept", "1") 37 | 38 | assert.Equal(t, "ok", target.attachment.Actions[0].Name) 39 | assert.Equal(t, "accept", target.attachment.Actions[0].Text) 40 | assert.Equal(t, "button", target.attachment.Actions[0].Type) 41 | assert.Equal(t, "1", target.attachment.Actions[0].Value) 42 | } 43 | 44 | func TestField(t *testing.T) { 45 | target := NewState("test") 46 | target.Field("field title", "field value") 47 | 48 | assert.Equal(t, "field value", target.attachment.Fields[0].Value) 49 | assert.Equal(t, "field title", target.attachment.Fields[0].Title) 50 | } 51 | 52 | func TestEvent(t *testing.T) { 53 | ev := func(slack.InteractionCallback) { 54 | 55 | } 56 | 57 | target := NewState("test") 58 | target.Event("hoge event", "fuga event", ev) 59 | 60 | assert.Equal(t, "hoge event", target.events["hoge event"].Name) 61 | assert.Equal(t, "fuga event", target.events["hoge event"].To) 62 | } 63 | -------------------------------------------------------------------------------- /event_api_handler.go: -------------------------------------------------------------------------------- 1 | package gobot 2 | 3 | import ( 4 | "net/http" 5 | "bytes" 6 | "encoding/json" 7 | 8 | "github.com/slack-go/slack" 9 | "github.com/slack-go/slack/slackevents" 10 | ) 11 | 12 | type eventApiHandler struct { 13 | slackClient *slack.Client 14 | verificationToken string 15 | firstMessage string 16 | bot *Gobot 17 | } 18 | 19 | func (g *Gobot) NewEventApiHandler(verificationToken string, firstMessage string, slackAccessToken string) eventApiHandler { 20 | client := slack.New(slackAccessToken) 21 | return eventApiHandler{client, verificationToken, firstMessage, g} 22 | } 23 | 24 | func (h eventApiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 25 | buf := new(bytes.Buffer) 26 | buf.ReadFrom(r.Body) 27 | body := buf.String() 28 | event, e := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionVerifyToken(&slackevents.TokenComparator{VerificationToken: h.verificationToken})) 29 | 30 | if e != nil { 31 | w.WriteHeader(http.StatusInternalServerError) 32 | } 33 | 34 | switch event.Type { 35 | case slackevents.URLVerification: 36 | var r *slackevents.ChallengeResponse 37 | err := json.Unmarshal([]byte(body), &r) 38 | if err != nil { 39 | w.WriteHeader(http.StatusInternalServerError) 40 | } 41 | w.Header().Set("Content-Type", "text") 42 | w.Write([]byte(r.Challenge)) 43 | case slackevents.CallbackEvent: 44 | mention, ok := event.InnerEvent.Data.(*slackevents.AppMentionEvent) 45 | if !ok { 46 | w.WriteHeader(http.StatusInternalServerError) 47 | return 48 | } 49 | 50 | attachment := slack.Attachment{ 51 | Text: h.firstMessage, 52 | Color: "#f9a41b", 53 | CallbackID: "selectingMachine", 54 | Actions: []slack.AttachmentAction{ 55 | { 56 | Name: "selectMachine", 57 | Type: "select", 58 | Options: h.bot.GetMachines(), 59 | }, 60 | }, 61 | } 62 | 63 | h.slackClient.PostMessage(mention.Channel, slack.MsgOptionAttachments(attachment)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 5 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 6 | github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 7 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 8 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 9 | github.com/lusis/go-slackbot v0.0.0-20180109053408-401027ccfef5/go.mod h1:c2mYKRyMb1BPkO5St0c/ps62L4S0W2NAkaTXj9qEI+0= 10 | github.com/lusis/slack-test v0.0.0-20180109053238-3c758769bfa6/go.mod h1:sFlOUpQL1YcjhFVXhg1CG8ZASEs/Mf1oVb6H75JL/zg= 11 | github.com/nlopes/slack v0.6.0/go.mod h1:JzQ9m3PMAqcpeCam7UaHSuBuupz7CmpjehYMayT6YOk= 12 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 14 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/slack-go/slack v0.6.3 h1:qU037g8gQ71EuH6S9zYKnvYrEUj0fLFH4HFekFqBoRU= 18 | github.com/slack-go/slack v0.6.3/go.mod h1:HE4RwNe7YpOg/F0vqo5PwXH3Hki31TplTvKRW9dGGaw= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 21 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 22 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 23 | -------------------------------------------------------------------------------- /machine/state.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "github.com/slack-go/slack" 5 | ) 6 | 7 | type State struct { 8 | name string 9 | attachment *slack.Attachment 10 | Events map[string]Event 11 | BuildAttachmentFunc func(slack.InteractionCallback) 12 | initial bool 13 | End bool 14 | } 15 | 16 | type Event struct { 17 | Name string 18 | Process func(slack.InteractionCallback) 19 | To string 20 | } 21 | 22 | type SelectBoxOption struct { 23 | Text string 24 | Value string 25 | } 26 | 27 | func NewState(name string) *State { 28 | events := map[string]Event{} 29 | return &State{name, &slack.Attachment{Color: "#f9a41b", CallbackID: name}, events, nil, false, false} 30 | } 31 | 32 | func (s *State) InitialState() { 33 | s.initial = true 34 | } 35 | 36 | func (s *State) EndState() { 37 | s.End = true 38 | } 39 | 40 | func (s *State) Color(c string) { 41 | s.attachment.Color = c 42 | } 43 | 44 | func (s *State) Text(t string) { 45 | s.attachment.Text = t 46 | } 47 | 48 | func (s *State) Button(eventName string, text string, value string) { 49 | button := slack.AttachmentAction { 50 | Name: eventName, 51 | Text: text, 52 | Type: "button", 53 | Value: value, 54 | } 55 | 56 | s.attachment.Actions = append(s.attachment.Actions , button) 57 | } 58 | 59 | func (s *State) Field(title string, value string) { 60 | field := slack.AttachmentField { 61 | Title: title, 62 | Value: value, 63 | } 64 | 65 | s.attachment.Fields = append(s.attachment.Fields, field) 66 | } 67 | 68 | func (s *State) SelectBox(eventName string, options []SelectBoxOption) { 69 | var slackOptions []slack.AttachmentActionOption 70 | 71 | for _, op := range options { 72 | slackOptions = append(slackOptions, slack.AttachmentActionOption{Text: op.Text, Value: op.Value}) 73 | } 74 | selectBox := slack.AttachmentAction { 75 | Name: eventName, 76 | Type: "select", 77 | Options: slackOptions, 78 | } 79 | 80 | s.attachment.Actions = append(s.attachment.Actions , selectBox) 81 | } 82 | 83 | func (s *State) Event (name string, to string, e func(slack.InteractionCallback)) { 84 | s.Events[name] = Event{ 85 | Name: name, 86 | To: to, 87 | Process: e, 88 | } 89 | } 90 | 91 | func (s *State) BuildAttachment(e func(slack.InteractionCallback)) { 92 | s.BuildAttachmentFunc = e 93 | } 94 | 95 | func (s *State) clearAttachment() { 96 | *s.attachment = slack.Attachment{Color: "#f9a41b", CallbackID: s.name} 97 | } 98 | -------------------------------------------------------------------------------- /examples/food/food.go: -------------------------------------------------------------------------------- 1 | package food 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ogidow/gobot/machine" 7 | "github.com/slack-go/slack" 8 | ) 9 | 10 | type Food struct { 11 | Name string 12 | } 13 | 14 | func NewMachine() machine.Machine { 15 | var food Food 16 | machine := machine.NewMachine("orderFood") 17 | machine.AddState("selecting", food.selectingState()) 18 | machine.AddState("cheking", food.chekingState()) 19 | machine.AddState("canceling", food.cancelingState()) 20 | machine.AddState("finish", food.finishState()) 21 | 22 | return *machine 23 | } 24 | 25 | func (f *Food) selectingState() func(s *machine.State) { 26 | return func(s *machine.State) { 27 | s.InitialState() 28 | s.BuildAttachment(func(ev slack.InteractionCallback) { 29 | options := []machine.SelectBoxOption { 30 | {Text: "Pizza", Value: "Pizza"}, 31 | {Text: "Sandwich", Value: "Sandwich"}, 32 | {Text: "Hamburger", Value: "Hamburger"}, 33 | } 34 | s.Text("with food do you want?") 35 | s.SelectBox("selectFood", options) 36 | s.Button("canceling", "cancel", "false") 37 | }) 38 | 39 | s.Event("selectFood", "cheking", func(ev slack.InteractionCallback){ 40 | // writing selectFood event logic 41 | f.Name = ev.Actions[0].SelectedOptions[0].Value 42 | }) 43 | s.Event("canceling", "canceling", func(ev slack.InteractionCallback){ 44 | // writing canceling event logic 45 | }) 46 | } 47 | } 48 | 49 | func (f *Food) cancelingState() func(s *machine.State) { 50 | return func(s *machine.State) { 51 | s.EndState() 52 | 53 | s.BuildAttachment(func(ev slack.InteractionCallback) { 54 | s.Text("I will wait for the next use") 55 | }) 56 | } 57 | } 58 | 59 | func (f *Food) chekingState() func(s *machine.State) { 60 | return func(s *machine.State) { 61 | s.BuildAttachment(func(ev slack.InteractionCallback) { 62 | s.Text(fmt.Sprintf("Okay, so that’s one %s?", f.Name)) 63 | s.Button("accept", "yes", "yes") 64 | s.Button("decline", "no", "no") 65 | }) 66 | 67 | s.Event("accept", "finish", func(ev slack.InteractionCallback){ 68 | // writing accept logic 69 | }) 70 | s.Event("decline", "selecting", func(ev slack.InteractionCallback){ 71 | // writing canceling logic 72 | }) 73 | } 74 | } 75 | 76 | func (f *Food) finishState() func(s *machine.State) { 77 | return func(s *machine.State) { 78 | s.EndState() 79 | 80 | s.BuildAttachment(func(ev slack.InteractionCallback) { 81 | s.Text(fmt.Sprintf("I received an order with %s", f.Name)) 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gobot 2 | gobot is a bot framework that uses slack interactive message 3 | 4 | ## Usage 5 | 6 | ### Create gobot and Machine 7 | In gobot, a series of interactions are called `Machine`. 8 | gobot can register multiple` Machines`. 9 | gobot can be created in the following way. 10 | 11 | ```go 12 | bot := gobot.NewGobot() 13 | ``` 14 | 15 | `Machine` can be created in the following way. 16 | 17 | ```golang 18 | m := machine.NewMachine("machineName") 19 | ``` 20 | 21 | `Machine` consists of multiple` State`. 22 | `State` is a slack message itself, and transitions to the next` State` when an event is fired for `State`. 23 | 24 | You can add `State` to` Machine` as follows 25 | 26 | ```go 27 | m.AddState("asking", func(s *machine.State) { 28 | s.InitialState() 29 | s.BuildAttachment(func(ev slack.InteractionCallback) { 30 | s.Text("Do you drink beer?") 31 | s.Button("accept", "yes", "yes") 32 | s.Button("decline", "no", "no") 33 | }) 34 | s.Event("accept", "ordering", func(ev slack.InteractionCallback){}) 35 | s.Event("decline", "canceling", func(ev slack.InteractionCallback){}) 36 | }) 37 | ``` 38 | 39 | Call `s.InitialState ()` for the first `State` of` Machine` and `s.EndState ()` for the last `State`. 40 | 41 | 42 | You can define a Slack Attachment using DSL in `s.BuildAttachment` method. 43 | You can also define an Event for `State` in` s.Event` method. 44 | In the above example, when the `yes` button is pressed, the` accept` event fires and transitions to the `ordering` State. 45 | 46 | 47 | ### Register Machine to gobot 48 | You can register the created machine in gobot by the following method 49 | 50 | ```go 51 | bot.AddMachine(food.NewMachine()) 52 | ``` 53 | 54 | ### Handle slack api 55 | gobot provides a slack api handler. 56 | gobot uses the event api 'app_mention' to handle mentions. 57 | 58 | You can register a handler in the following way to listen to slack api. 59 | 60 | ```go 61 | slackVerificationToken := "YOURE_SLACK_VERIFICATION_TOKEN" 62 | SlackAccessToken := "YOURE_SLACK_ACCESS_TOKEN" 63 | eventHandler := bot.NewEventApiHandler(slackVerificationToken, "what do you do?", SlackAccessToken) 64 | http.Handle("/path/to/your_event_api", eventHandler) 65 | 66 | interactiveHandler := bot.NewInteractiveApiHandler() 67 | http.Handle("/path/to/your_interactive_messages_api", interactiveHandler) 68 | 69 | http.ListenAndServe(":" + os.Getenv("PORT"), nil) 70 | ``` 71 | 72 | ### Example 73 | See https://github.com/ogidow/gobot/blob/master/examples 74 | 75 | ![](/examples/gobot_example.gif) 76 | --------------------------------------------------------------------------------