├── .gitignore ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── README.md ├── beerbot.gif ├── cloudbuild.yaml ├── handler.go ├── main.go └── slack.go /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | bot 3 | vendor -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | ADD app /app 3 | CMD ["./app"] 4 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | memo = "5c937e881b209526d957519d68a378bab69b02fcb3ce6c0170f88e3edaffe2b9" 2 | 3 | [[projects]] 4 | branch = "master" 5 | name = "github.com/kelseyhightower/envconfig" 6 | packages = ["."] 7 | revision = "b6fde1625d631a48340817849a547164f395d9eb" 8 | 9 | [[projects]] 10 | branch = "master" 11 | name = "github.com/nlopes/slack" 12 | packages = ["."] 13 | revision = "72d15a0fc0b773a59c00f78b9e7d97eeb8c4281f" 14 | 15 | [[projects]] 16 | branch = "master" 17 | name = "golang.org/x/net" 18 | packages = ["websocket"] 19 | revision = "84f0e6f92b10139f986b1756e149a7d9de270cdc" 20 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | ## Gopkg.toml example (these lines may be deleted) 3 | 4 | ## "required" lists a set of packages (not projects) that must be included in 5 | ## Gopkg.lock. This list is merged with the set of packages imported by the current 6 | ## project. Use it when your project needs a package it doesn't explicitly import - 7 | ## including "main" packages. 8 | # required = ["github.com/user/thing/cmd/thing"] 9 | 10 | ## "ignored" lists a set of packages (not projects) that are ignored when 11 | ## dep statically analyzes source code. Ignored packages can be in this project, 12 | ## or in a dependency. 13 | # ignored = ["github.com/user/project/badpkg"] 14 | 15 | ## Dependencies define constraints on dependent projects. They are respected by 16 | ## dep whether coming from the Gopkg.toml of the current project or a dependency. 17 | # [[dependencies]] 18 | ## Required: the root import path of the project being constrained. 19 | # name = "github.com/user/project" 20 | # 21 | ## Recommended: the version constraint to enforce for the project. 22 | ## Only one of "branch", "version" or "revision" can be specified. 23 | # version = "1.0.0" 24 | # branch = "master" 25 | # revision = "abc123" 26 | # 27 | ## Optional: an alternate location (URL or import path) for the project's source. 28 | # source = "https://github.com/myfork/package.git" 29 | 30 | ## Overrides have the same structure as [[dependencies]], but supercede all 31 | ## [[dependencies]] declarations from all projects. Only the current project's 32 | ## [[overrides]] are applied. 33 | ## 34 | ## Overrides are a sledgehammer. Use them only as a last resort. 35 | # [[overrides]] 36 | ## Required: the root import path of the project being constrained. 37 | # name = "github.com/user/project" 38 | # 39 | ## Optional: specifying a version constraint override will cause all other 40 | ## constraints on this project to be ignored; only the overriden constraint 41 | ## need be satisfied. 42 | ## Again, only one of "branch", "version" or "revision" can be specified. 43 | # version = "1.0.0" 44 | # branch = "master" 45 | # revision = "abc123" 46 | # 47 | ## Optional: specifying an alternate source location as an override will 48 | ## enforce that the alternate location is used for that project, regardless of 49 | ## what source location any dependent projects specify. 50 | # source = "https://github.com/myfork/package.git" 51 | 52 | 53 | 54 | [[dependencies]] 55 | branch = "master" 56 | name = "github.com/kelseyhightower/envconfig" 57 | 58 | [[dependencies]] 59 | branch = "master" 60 | name = "github.com/nlopes/slack" 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slack interactive message in Golang 2 | 3 | This is sample slack bot which uses [slack interactive message](https://api.slack.com/interactive-messages) written in Golang. To run this bot, you need to create a bot as [slack app](https://api.slack.com/slack-apps). To create slack app only for your team, you can use [internal integrations](https://api.slack.com/internal-integrations) (No OAuth required. This code does not implement it, so if you want to distribute it, you need to write it by yourself). Create a new app from [here](https://api.slack.com/apps). The following is how this bot works. Feel free to fork this repository and write your own! Cheers :beer: 4 | 5 | ![](/beerbot.gif) 6 | 7 | (NOTE: Order is fake...) 8 | 9 | ## Usage 10 | 11 | To run this bot, you need to set the following env vars, 12 | 13 | ```bash 14 | export BOT_ID="U***" // you can get this after create a bot user (via slack app management console) 15 | export BOT_TOKEN="xoxb-***" // you can get this after create a bot user (via slack app management console) 16 | export VERIFICATION_TOKEN="***" // you can get this after enable interactive message (via slack app management console) 17 | export CHANNEL_ID="C***" // bot reacts only this channel 18 | ``` 19 | 20 | To run this, 21 | 22 | ```bash 23 | $ dep ensure 24 | $ go build -o bot && ./bot 25 | ``` 26 | 27 | To run this local, use `ngrok` (See more about it [here](https://api.slack.com/tutorials/tunneling-with-ngrok)) and set it for interactive message requests endpoint. 28 | 29 | ## References 30 | 31 | - https://github.com/slackapi/sample-message-menus-node 32 | -------------------------------------------------------------------------------- /beerbot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tcnksm/go-slack-interactive/cd4a729902eb40c01181fe83c042e9206f058699/beerbot.gif -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: gcr.io/cloud-builders/go 3 | env: ["PROJECT_ROOT=app"] 4 | args: ["build", "-o", "app"] 5 | 6 | - name: "gcr.io/cloud-builders/docker" 7 | args: ["build", "-t", "gcr.io/$PROJECT_ID/bot:0.0.1", "." ] 8 | 9 | images: 10 | - "gcr.io/$PROJECT_ID/bot:0.0.1" 11 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | "github.com/nlopes/slack" 13 | ) 14 | 15 | // interactionHandler handles interactive message response. 16 | type interactionHandler struct { 17 | slackClient *slack.Client 18 | verificationToken string 19 | } 20 | 21 | func (h interactionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 22 | if r.Method != http.MethodPost { 23 | log.Printf("[ERROR] Invalid method: %s", r.Method) 24 | w.WriteHeader(http.StatusMethodNotAllowed) 25 | return 26 | } 27 | 28 | buf, err := ioutil.ReadAll(r.Body) 29 | if err != nil { 30 | log.Printf("[ERROR] Failed to read request body: %s", err) 31 | w.WriteHeader(http.StatusInternalServerError) 32 | return 33 | } 34 | 35 | jsonStr, err := url.QueryUnescape(string(buf)[8:]) 36 | if err != nil { 37 | log.Printf("[ERROR] Failed to unespace request body: %s", err) 38 | w.WriteHeader(http.StatusInternalServerError) 39 | return 40 | } 41 | 42 | var message slack.AttachmentActionCallback 43 | if err := json.Unmarshal([]byte(jsonStr), &message); err != nil { 44 | log.Printf("[ERROR] Failed to decode json message from slack: %s", jsonStr) 45 | w.WriteHeader(http.StatusInternalServerError) 46 | return 47 | } 48 | 49 | // Only accept message from slack with valid token 50 | if message.Token != h.verificationToken { 51 | log.Printf("[ERROR] Invalid token: %s", message.Token) 52 | w.WriteHeader(http.StatusUnauthorized) 53 | return 54 | } 55 | 56 | action := message.Actions[0] 57 | switch action.Name { 58 | case actionSelect: 59 | value := action.SelectedOptions[0].Value 60 | 61 | // Overwrite original drop down message. 62 | originalMessage := message.OriginalMessage 63 | originalMessage.Attachments[0].Text = fmt.Sprintf("OK to order %s ?", strings.Title(value)) 64 | originalMessage.Attachments[0].Actions = []slack.AttachmentAction{ 65 | { 66 | Name: actionStart, 67 | Text: "Yes", 68 | Type: "button", 69 | Value: "start", 70 | Style: "primary", 71 | }, 72 | { 73 | Name: actionCancel, 74 | Text: "No", 75 | Type: "button", 76 | Style: "danger", 77 | }, 78 | } 79 | 80 | w.Header().Add("Content-type", "application/json") 81 | w.WriteHeader(http.StatusOK) 82 | json.NewEncoder(w).Encode(&originalMessage) 83 | return 84 | case actionStart: 85 | title := ":ok: your order was submitted! yay!" 86 | responseMessage(w, message.OriginalMessage, title, "") 87 | return 88 | case actionCancel: 89 | title := fmt.Sprintf(":x: @%s canceled the request", message.User.Name) 90 | responseMessage(w, message.OriginalMessage, title, "") 91 | return 92 | default: 93 | log.Printf("[ERROR] ]Invalid action was submitted: %s", action.Name) 94 | w.WriteHeader(http.StatusInternalServerError) 95 | return 96 | } 97 | } 98 | 99 | // responseMessage response to the original slackbutton enabled message. 100 | // It removes button and replace it with message which indicate how bot will work 101 | func responseMessage(w http.ResponseWriter, original slack.Message, title, value string) { 102 | original.Attachments[0].Actions = []slack.AttachmentAction{} // empty buttons 103 | original.Attachments[0].Fields = []slack.AttachmentField{ 104 | { 105 | Title: title, 106 | Value: value, 107 | Short: false, 108 | }, 109 | } 110 | 111 | w.Header().Add("Content-type", "application/json") 112 | w.WriteHeader(http.StatusOK) 113 | json.NewEncoder(w).Encode(&original) 114 | } 115 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/kelseyhightower/envconfig" 9 | "github.com/nlopes/slack" 10 | ) 11 | 12 | // https://api.slack.com/slack-apps 13 | // https://api.slack.com/internal-integrations 14 | type envConfig struct { 15 | // Port is server port to be listened. 16 | Port string `envconfig:"PORT" default:"3000"` 17 | 18 | // BotToken is bot user token to access to slack API. 19 | BotToken string `envconfig:"BOT_TOKEN" required:"true"` 20 | 21 | // VerificationToken is used to validate interactive messages from slack. 22 | VerificationToken string `envconfig:"VERIFICATION_TOKEN" required:"true"` 23 | 24 | // BotID is bot user ID. 25 | BotID string `envconfig:"BOT_ID" required:"true"` 26 | 27 | // ChannelID is slack channel ID where bot is working. 28 | // Bot responses to the mention in this channel. 29 | ChannelID string `envconfig:"CHANNEL_ID" required:"true"` 30 | } 31 | 32 | func main() { 33 | os.Exit(_main(os.Args[1:])) 34 | } 35 | 36 | func _main(args []string) int { 37 | var env envConfig 38 | if err := envconfig.Process("", &env); err != nil { 39 | log.Printf("[ERROR] Failed to process env var: %s", err) 40 | return 1 41 | } 42 | 43 | // Listening slack event and response 44 | log.Printf("[INFO] Start slack event listening") 45 | client := slack.New(env.BotToken) 46 | slackListener := &SlackListener{ 47 | client: client, 48 | botID: env.BotID, 49 | channelID: env.ChannelID, 50 | } 51 | go slackListener.ListenAndResponse() 52 | 53 | // Register handler to receive interactive message 54 | // responses from slack (kicked by user action) 55 | http.Handle("/interaction", interactionHandler{ 56 | verificationToken: env.VerificationToken, 57 | }) 58 | 59 | log.Printf("[INFO] Server listening on :%s", env.Port) 60 | if err := http.ListenAndServe(":"+env.Port, nil); err != nil { 61 | log.Printf("[ERROR] %s", err) 62 | return 1 63 | } 64 | 65 | return 0 66 | } 67 | -------------------------------------------------------------------------------- /slack.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/nlopes/slack" 9 | ) 10 | 11 | const ( 12 | // action is used for slack attament action. 13 | actionSelect = "select" 14 | actionStart = "start" 15 | actionCancel = "cancel" 16 | ) 17 | 18 | type SlackListener struct { 19 | client *slack.Client 20 | botID string 21 | channelID string 22 | } 23 | 24 | // LstenAndResponse listens slack events and response 25 | // particular messages. It replies by slack message button. 26 | func (s *SlackListener) ListenAndResponse() { 27 | rtm := s.client.NewRTM() 28 | 29 | // Start listening slack events 30 | go rtm.ManageConnection() 31 | 32 | // Handle slack events 33 | for msg := range rtm.IncomingEvents { 34 | switch ev := msg.Data.(type) { 35 | case *slack.MessageEvent: 36 | if err := s.handleMessageEvent(ev); err != nil { 37 | log.Printf("[ERROR] Failed to handle message: %s", err) 38 | } 39 | } 40 | } 41 | } 42 | 43 | // handleMesageEvent handles message events. 44 | func (s *SlackListener) handleMessageEvent(ev *slack.MessageEvent) error { 45 | // Only response in specific channel. Ignore else. 46 | if ev.Channel != s.channelID { 47 | log.Printf("%s %s", ev.Channel, ev.Msg.Text) 48 | return nil 49 | } 50 | 51 | // Only response mention to bot. Ignore else. 52 | if !strings.HasPrefix(ev.Msg.Text, fmt.Sprintf("<@%s> ", s.botID)) { 53 | return nil 54 | } 55 | 56 | // Parse message 57 | m := strings.Split(strings.TrimSpace(ev.Msg.Text), " ")[1:] 58 | if len(m) == 0 || m[0] != "hey" { 59 | return fmt.Errorf("invalid message") 60 | } 61 | 62 | // value is passed to message handler when request is approved. 63 | attachment := slack.Attachment{ 64 | Text: "Which beer do you want? :beer:", 65 | Color: "#f9a41b", 66 | CallbackID: "beer", 67 | Actions: []slack.AttachmentAction{ 68 | { 69 | Name: actionSelect, 70 | Type: "select", 71 | Options: []slack.AttachmentActionOption{ 72 | { 73 | Text: "Asahi Super Dry", 74 | Value: "Asahi Super Dry", 75 | }, 76 | { 77 | Text: "Kirin Lager Beer", 78 | Value: "Kirin Lager Beer", 79 | }, 80 | { 81 | Text: "Sapporo Black Label", 82 | Value: "Sapporo Black Label", 83 | }, 84 | { 85 | Text: "Suntory Malts", 86 | Value: "Suntory Malts", 87 | }, 88 | { 89 | Text: "Yona Yona Ale", 90 | Value: "Yona Yona Ale", 91 | }, 92 | }, 93 | }, 94 | 95 | { 96 | Name: actionCancel, 97 | Text: "Cancel", 98 | Type: "button", 99 | Style: "danger", 100 | }, 101 | }, 102 | } 103 | 104 | params := slack.PostMessageParameters{ 105 | Attachments: []slack.Attachment{ 106 | attachment, 107 | }, 108 | } 109 | 110 | if _, _, err := s.client.PostMessage(ev.Channel, "", params); err != nil { 111 | return fmt.Errorf("failed to post message: %s", err) 112 | } 113 | 114 | return nil 115 | } 116 | --------------------------------------------------------------------------------