From d3183d0d99f3780504e90bd00acc06a2f067e0f9 Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Sun, 29 Apr 2018 15:25:26 +0300 Subject: [PATCH 1/9] Version 1.0.0 * Using Slack RTM * Configuration with config file, environment variables and flags * Live reload config file --- .gitignore | 5 +- CHANGELOG.md | 12 +++ Gopkg.lock | 135 ++++++++++++++++++++++++++++++++ Gopkg.toml | 38 +++++++++ Makefile | 12 +++ README.md | 61 +++++++++++---- ROADMAP.md | 7 ++ bin/.gitkeep | 0 main.go | 153 ++++++++++++++++++++++++++++++++++++ pkg/.gitkeep | 0 src/slack-duty-bot/main.go | 155 ------------------------------------- 11 files changed, 407 insertions(+), 171 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 Makefile create mode 100644 ROADMAP.md delete mode 100644 bin/.gitkeep create mode 100644 main.go delete mode 100644 pkg/.gitkeep delete mode 100644 src/slack-duty-bot/main.go diff --git a/.gitignore b/.gitignore index 10c2cb0..0904bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,7 @@ .glide/ /.idea !/bin/.gitkeep -/bin/* \ No newline at end of file +/bin/* + +/vendor +slack-duty-bot diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..483e244 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +CHANGELOG +========= + +1.0.0 +----- + * Using Slack RTM + * Configuration with config file, environment variables and flags + * Live reload config file + +Init +---- + * Simple slack-duty-bot working with http outgoing slack webhook diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..e587eb8 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,135 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/fsnotify/fsnotify" + packages = ["."] + revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" + version = "v1.4.7" + +[[projects]] + name = "github.com/gorilla/websocket" + packages = ["."] + revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" + version = "v1.2.0" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/hcl" + packages = [ + ".", + "hcl/ast", + "hcl/parser", + "hcl/printer", + "hcl/scanner", + "hcl/strconv", + "hcl/token", + "json/parser", + "json/scanner", + "json/token" + ] + revision = "ef8a98b0bbce4a65b5aa4c368430a80ddc533168" + +[[projects]] + name = "github.com/magiconair/properties" + packages = ["."] + revision = "c3beff4c2358b44d0493c7dda585e7db7ff28ae6" + version = "v1.7.6" + +[[projects]] + branch = "master" + name = "github.com/mitchellh/mapstructure" + packages = ["."] + revision = "00c29f56e2386353d58c599509e8dc3801b0d716" + +[[projects]] + name = "github.com/nlopes/slack" + packages = ["."] + revision = "8ab4d0b364ef1e9af5d102531da20d5ec902b6c4" + version = "v0.2.0" + +[[projects]] + name = "github.com/pelletier/go-toml" + packages = ["."] + revision = "acdc4509485b587f5e675510c4f2c63e90ff68a8" + version = "v1.1.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + name = "github.com/spf13/afero" + packages = [ + ".", + "mem" + ] + revision = "63644898a8da0bc22138abf860edaf5277b6102e" + version = "v1.1.0" + +[[projects]] + name = "github.com/spf13/cast" + packages = ["."] + revision = "8965335b8c7107321228e3e3702cab9832751bac" + version = "v1.2.0" + +[[projects]] + branch = "master" + name = "github.com/spf13/jwalterweatherman" + packages = ["."] + revision = "7c0cea34c8ece3fbeb2b27ab9b59511d360fb394" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "583c0c0531f06d5278b7d917446061adc344b5cd" + version = "v1.0.1" + +[[projects]] + name = "github.com/spf13/viper" + packages = ["."] + revision = "b5e8006cbee93ec955a89ab31e0e3ce3204f3736" + version = "v1.0.2" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "b49d69b5da943f7ef3c9cf91c8777c1f78a0cc3c" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "cbbc999da32df943dac6cd71eb3ee39e1d7838b9" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "internal/gen", + "internal/triegen", + "internal/ucd", + "transform", + "unicode/cldr", + "unicode/norm" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "d3a415f3af1a28399f44a9084b7e1d220ccf60870db7dc4f8fbadb9f01cc10a1" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..0cd69d0 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,38 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + name = "github.com/spf13/pflag" + version = "1.0.1" + +[[constraint]] + name = "github.com/spf13/viper" + version = "1.0.2" + +[prune] + go-tests = true + unused-packages = true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfd3864 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +all: make + +BUILD_OS:=linux +BUILD_ARCH:=amd64 + +dep-install: + go get -v -u github.com/golang/dep/cmd/dep +dep-ensure: + dep ensure +build: + GOOS=${BUILD_OS} GOARCH=${BUILD_ARCH} go build -v +make: dep-install dep-ensure build diff --git a/README.md b/README.md index 56a163b..514c42e 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,58 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/iqoption/slack-duty-bot)](https://goreportcard.com/report/github.com/iqoption/slack-duty-bot) ### How usage -1. Create new custom integration `Outgoing WebHooks` (e.g https://{team}.slack.com/apps/manage/custom-integrations) +1. Create new custom integration `Bots` (e.g https://{team}.slack.com/apps/manage/custom-integrations) 2. Build for your environment -3. Add a schedule for the attendants +3. Prepare config.yaml with duties list 3. Run with the required parameters +```bash +SDB_SLACK_TOKEN=your-token-here ./slack-duty-bot \ + --slack.keywords keyword-1 \ + --slack.keywords keyword-2 \ + --slack.group.id your-group-id \ + --slack.group.name your-group-name +``` + ### Build package +Build +```bash +go get -u github.com/golang/dep/cmd/dep +dep ensure +env GOOS=linux GOARCH=amd64 go build -v +``` +Build via makefile +```bash +make BUILD_OS=linux BUILD_ARCH=amd64 ``` -env GOOS=linux GOARCH=amd64 go get -v -d ./src/slack-duty-bot -env GOOS=linux GOARCH=amd64 go build -o ./bin/slack-duty-bot ./src/slack-duty-bot/main.go +Build in docker +```bash +docker run --rm -v $(pwd):/go/src/slack-duty-bot -w /go/src/slack-duty-bot golang:1.10 make BUILD_OS=linux BUILD_ARCH=amd64 ``` -### Config +### Configuration flags, environment variables +Environment variables are prefixed with `SDB_` and *MUST* be uppercase with `_` delimiter +Available variables: +* `SDB_CONFIG_PATH` +* `SDB_SLACK_TOKEN` +* `SDB_SLACK_GROUP_ID` +* `SDB_SLACK_GROUP_NAME` +* `SDB_SLACK_THREADS` + +Every environment variable can be overwritten by startup flags +Available flags: +* `--config.path` - path to yml config (default: . and $HOME/.slack-duty-bot) +* `--slack.token` - Slack API client token +* `--slack.keywords` - Case insensitive keywords to search in message text, can be set multiple times (default: []) +* `--slack.group.name` - Slack user group name, to mention in channel if duty list is empty +* `--slack.group.id` - Slack user group ID, to mention in channel if duty list is empty +* `--slack.threads` - Case insensitive keywords to search in message text, can be set multiple times (default: true) + +You can get IDS from api or just use [testing page](https://api.slack.com/methods/usergroups.list/test) + +### Configuration file +Configuration file *MUST* contain `duties` key with *7* slices of Slack username ```yaml -token: %some-token% -log: /var/log/slack-duty-bot.log -ids: - username.one: U11GZZZZZ - username.two: U11VZZZZZ duties: - [username.one, username.two] # Sunday - [username.one] # Monday @@ -31,8 +65,5 @@ duties: - [username.one, username.two] # Saturday ``` -### Available arguments -* `--port` - bot http listen port (default: 8003) -* `--config` - path to yml config (default: /var/slack-bot/config.yml) -* `--period` - period in seconds after which the config will be updated (default: 5) -* `--restore` - enable restore previous stable config version process \ No newline at end of file +### Changelog +1.0.0 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..ee0e280 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +* Signal handlers +* HTTP rest API for edit duties configuration +* Persistent storage for duties configuration +* Multiple instance mode with ex-locks diff --git a/bin/.gitkeep b/bin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/main.go b/main.go new file mode 100644 index 0000000..217f7a1 --- /dev/null +++ b/main.go @@ -0,0 +1,153 @@ +package main + +import ( + "fmt" + "github.com/fsnotify/fsnotify" + "github.com/nlopes/slack" + log "github.com/sirupsen/logrus" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "strings" + "time" +) + +const ( + incomingErrorRetry = 100 +) + +func init() { + viper.SetEnvPrefix("SDB") + + pflag.String("config.path", "", "Config path") + pflag.String("slack.token", "", "Slack API client token config") + pflag.String("slack.group.name", "", "Slack group ID for calling in fallback mode") + pflag.String("slack.group.id", "", "Slack group name for calling in fallback mode") + pflag.StringSlice("slack.keywords", []string{}, "Slack keywords to lister") + pflag.Bool("slack.threads", true, "Usage of Slack threads to reply on messages") + viper.BindPFlags(pflag.CommandLine) + + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + viper.BindEnv("config.path") + viper.BindEnv("slack.token") + viper.BindEnv("slack.group.id") + viper.BindEnv("slack.group.name") + viper.BindEnv("slack.threads") + + viper.SetConfigType("yaml") + viper.AddConfigPath("$HOME/.slack-duty-bot") + viper.AddConfigPath(".") + + viper.SetDefault("config.path", "/etc/slack-duty-bot") + + log.SetFormatter(&log.TextFormatter{DisableColors: true}) + log.SetLevel(log.DebugLevel) +} + +func main() { + pflag.Parse() + viper.ReadInConfig() + + if viper.GetString("slack.token") == "" { + log.Fatalln("Parameter slack.token is required") + } + if len(viper.GetStringSlice("slack.keywords")) == 0 { + log.Fatalln("Parameter slack.keywords is required") + } + if viper.GetString("config.path") != "" { + viper.AddConfigPath(viper.GetString("config.path")) + } + + viper.WatchConfig() + viper.OnConfigChange(func(e fsnotify.Event) { + log.Infoln("Config file was changed") + }) + + var ( + client = slack.New(viper.GetString("slack.token")) + rtm = client.NewRTM() + ) + + go rtm.ManageConnection() + log.Infoln("Send request for RTM connection") + + var incomingErrorCount = 0 + for packet := range rtm.IncomingEvents { + log.Printf("Incoming event with type %s", packet.Type) + switch event := packet.Data.(type) { + case *slack.IncomingEventError: + incomingErrorCount++ + if incomingErrorCount >= incomingErrorRetry { + log.Fatalf("Reached error reconnect limit %d, terminate", incomingErrorRetry) + } + case *slack.ConnectedEvent: + log.Infoln("RTM connection established") + case *slack.MessageEvent: + log.Printf("Incoming message event") + if err := handleMessageEvent(rtm, event); err != nil { + log.Warningf("Handle message event error: %v", err) + } + } + } +} + +func handleMessageEvent(rtm *slack.RTM, event *slack.MessageEvent) error { + // check text + if event.Text == "" { + return fmt.Errorf("incoming message with empty text") + } + // check keywords + var keywords = viper.GetStringSlice("slack.keywords") + contains := any(keywords, func(keyword string) bool { + return strings.Contains(strings.ToLower(event.Text), strings.ToLower(keyword)) + }) + if contains == false { + return fmt.Errorf("incoming message text '%s' does not contain any suitable keywords (%s)", event.Text, strings.Join(keywords, ", ")) + } + log.Infof("Incoming message text: %s", event.Text) + // collection user ids for make duties list + var userIds = make(map[string]string, 0) + users, err := rtm.Client.GetUsers() + if err != nil { + log.Warningf("Failed to get users list from Slack API: %v", err) + } + if users != nil { + for _, user := range users { + userIds[user.Name] = user.ID + } + } + var ( + config = struct { + Duties [][]string // we need this hack cause viper cannot resolve slice of slice + }{} + duties []string + ) + viper.Unmarshal(&config) + for _, username := range config.Duties[int(time.Now().Weekday())] { + userId, ok := userIds[username] + if !ok { + log.Warningf("Failed to get user id by username %s", username) + } + duties = append(duties, fmt.Sprintf("<@%s|%s>", userId, username)) + } + if len(duties) == 0 && viper.GetString("slack.group.id") != "" && viper.GetString("slack.group.name") != "" { + duties = append(duties, fmt.Sprintf("", viper.GetString("slack.group.id"), viper.GetString("slack.group.name"))) + } + // send message + var outgoing = rtm.NewOutgoingMessage(strings.Join(duties, ", "), event.Channel) + if viper.GetBool("slack.threads") == true { + outgoing.ThreadTimestamp = event.Timestamp + } + log.Infof("Outgoing message: %+v", outgoing) + rtm.SendMessage(outgoing) + return nil +} + +func any(vs []string, f func(string) bool) bool { + for _, v := range vs { + if f(v) { + return true + } + } + return false +} diff --git a/pkg/.gitkeep b/pkg/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/slack-duty-bot/main.go b/src/slack-duty-bot/main.go deleted file mode 100644 index 58ea0f3..0000000 --- a/src/slack-duty-bot/main.go +++ /dev/null @@ -1,155 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "io/ioutil" - "log" - "net/http" - "os" - "strings" - "time" - - "gopkg.in/yaml.v2" -) - -type Config struct { - Token string - Log string - Ids map[string]string - Duties [][]string -} - -type Response struct { - Text string `json:"text"` -} - -var ( - booted = false - config = &Config{} - file = flag.String("config", "/var/slack-duty-bot/config.yml", "Full path to yml config") - port = flag.String("port", "8003", "HTTP port to listen by bot") - period = flag.Int("period", 5, "Period in seconds after which the config will be updated") - restore = flag.Bool("restore", false, "Enable restore previous stable config version process") -) - -func main() { - flag.Parse() - go updateConfig() - http.HandleFunc("/", rootHandler) - http.ListenAndServe(fmt.Sprintf(":%s", *port), nil) -} - -func readConfig() (*Config, error) { - content, err := ioutil.ReadFile(*file) - if err != nil { - return nil, err - } - current := &Config{} - if err := yaml.Unmarshal(content, current); err != nil { - return nil, err - } - if current.Token == "" { - return nil, errors.New("Config token is empty") - } - if len(current.Duties) < 7 { - return nil, errors.New(fmt.Sprintf("Invalid number (%d) of messages in config", len(config.Duties))) - } - if current.Log == "" { - return nil, errors.New("Config log is empty") - } - return current, nil -} - -func updateConfig() { - for { - current, err := readConfig() - if err != nil { - if !*restore || booted == false { - log.Panicln(fmt.Sprintf("Failed to init config. Error: %s", err.Error())) - } - log.Printf("An error occured during update config (\"%s\"). Restore previous version.", err.Error()) - current = config - } - config = current - if booted == false { - booted = true - } - time.Sleep(time.Second * time.Duration(*period)) - } -} - -func rootHandler(w http.ResponseWriter, r *http.Request) { - if result, err := validate(r.Body); !result { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - response, err := getResponse() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - js, err := json.Marshal(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(js) -} - -func validate(context io.ReadCloser) (result bool, err error) { - data, err := ioutil.ReadAll(context) - if err != nil { - return false, err - } - - body := string(data) - if !strings.Contains(body, config.Token) { - return false, errors.New("Bad token") - } - - descriptor, err := os.OpenFile(config.Log, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0755) - if err != nil { - return false, err - } - - defer descriptor.Close() - descriptor.WriteString(fmt.Sprintf("[%s] Incoming request body: %s", time.Now(), body)) - - return true, err -} - -func getResponse() (Response, error) { - duties, err := arrayMap(config.Duties[time.Now().Weekday()], func(user string) (string, error) { - id, ok := config.Ids[user] - if !ok { - return "", errors.New(fmt.Sprintf("Unknown user \"%s\" called from config", user)) - } - return fmt.Sprintf("<@%s|%s>", id, user), nil - }) - response := Response{} - if err != nil { - return response, err - } - response.Text = strings.Join(duties, " ") - return response, nil -} - -func arrayMap(array []string, callback func(string) (string, error)) ([]string, error) { - arrayCopy := make([]string, len(array)) - for index, value := range array { - value, err := callback(value) - if err != nil { - return nil, err - } - arrayCopy[index] = value - } - return arrayCopy, nil -} From 7de0dae6a16de74ad3758acc3d2bc205b07818d1 Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Sun, 29 Apr 2018 15:29:00 +0300 Subject: [PATCH 2/9] Remove useless block from README.md and resolve conflict --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 514c42e..7765a2e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,3 @@ duties: - [username.one] # Friday - [username.one, username.two] # Saturday ``` - -### Changelog -1.0.0 From 08558c6106b7fce850f149ffe57259ecac1b4681 Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Mon, 30 Apr 2018 01:42:51 +0300 Subject: [PATCH 3/9] Update make file, fix flags, process critical events from RTM --- .dockerignore | 4 ++++ .gitignore | 1 + Makefile | 45 ++++++++++++++++++++++++++++++++++++++------- README.md | 4 ++-- main.go | 31 +++++++++++++++++++++---------- 5 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b547f76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +*.md +Gopkg* +Makefile +LICENSE diff --git a/.gitignore b/.gitignore index 0904bb7..c1e3575 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ /vendor slack-duty-bot +config.yaml diff --git a/Makefile b/Makefile index cfd3864..f48ff87 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,43 @@ -all: make +all: build -BUILD_OS:=linux -BUILD_ARCH:=amd64 +APP?=slack-duty-bot +ROOT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) +BUILD_OS?=linux +BUILD_ARCH?=amd64 +DOCKER_IMAGE?=insidieux/${APP} +DOCKER_TAG?=1.0.0 +DOCKER_USER?=user +DOCKER_PASSWORD?=password +SDB_SLACK_TOKEN?=some-token +SDB_SLACK_KEYWORD?=keyword + +clean: + rm -f ${APP} dep-install: go get -v -u github.com/golang/dep/cmd/dep -dep-ensure: + +dep-ensure: dep-install + rm -rf vendor dep ensure -build: - GOOS=${BUILD_OS} GOARCH=${BUILD_ARCH} go build -v -make: dep-install dep-ensure build + +test: dep-ensure + go test -v -race ./... + +build: clean dep-ensure + env GOOS=${BUILD_OS} GOARCH=${BUILD_ARCH} CGO_ENABLED=0 go build -v -o ${APP} + +container: build + docker rmi ${DOCKER_IMAGE}:${DOCKER_TAG} || true + docker build --build-arg APP=${APP} -f .docker/Dockerfile -t ${DOCKER_IMAGE}:${DOCKER_TAG} . + +run: container + docker stop ${APP} || true && docker rm ${APP} || true + docker run --name ${APP} --rm \ + -e SDB_SLACK_TOKEN=${SDB_SLACK_TOKEN} \ + ${DOCKER_IMAGE}:${DOCKER_TAG} \ + --slack.keyword ${SDB_SLACK_KEYWORD} + +push: container + docker login docker.io -u ${DOCKER_USER} -p ${DOCKER_PASSWORD} + docker push ${DOCKER_IMAGE}:${DOCKER_TAG} diff --git a/README.md b/README.md index 7765a2e..f148810 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ Every environment variable can be overwritten by startup flags Available flags: * `--config.path` - path to yml config (default: . and $HOME/.slack-duty-bot) * `--slack.token` - Slack API client token -* `--slack.keywords` - Case insensitive keywords to search in message text, can be set multiple times (default: []) +* `--slack.keyword` - Case insensitive keywords slice to search in message text, can be set multiple times (default: []) * `--slack.group.name` - Slack user group name, to mention in channel if duty list is empty * `--slack.group.id` - Slack user group ID, to mention in channel if duty list is empty -* `--slack.threads` - Case insensitive keywords to search in message text, can be set multiple times (default: true) +* `--slack.threads` - Use threads as reply target or push message direct to channel (default: true) You can get IDS from api or just use [testing page](https://api.slack.com/methods/usergroups.list/test) diff --git a/main.go b/main.go index 217f7a1..7390967 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,7 @@ import ( ) const ( - incomingErrorRetry = 100 + incomingErrorRetry = 500 ) func init() { @@ -20,9 +20,10 @@ func init() { pflag.String("config.path", "", "Config path") pflag.String("slack.token", "", "Slack API client token config") + // We need ID and name only because bot users can't read user groups info via api pflag.String("slack.group.name", "", "Slack group ID for calling in fallback mode") pflag.String("slack.group.id", "", "Slack group name for calling in fallback mode") - pflag.StringSlice("slack.keywords", []string{}, "Slack keywords to lister") + pflag.StringSlice("slack.keyword", []string{}, "Slack keywords to lister") pflag.Bool("slack.threads", true, "Usage of Slack threads to reply on messages") viper.BindPFlags(pflag.CommandLine) @@ -30,6 +31,7 @@ func init() { viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) viper.BindEnv("config.path") viper.BindEnv("slack.token") + viper.BindEnv("slack.keyword") viper.BindEnv("slack.group.id") viper.BindEnv("slack.group.name") viper.BindEnv("slack.threads") @@ -51,8 +53,8 @@ func main() { if viper.GetString("slack.token") == "" { log.Fatalln("Parameter slack.token is required") } - if len(viper.GetStringSlice("slack.keywords")) == 0 { - log.Fatalln("Parameter slack.keywords is required") + if len(viper.GetStringSlice("slack.keyword")) == 0 { + log.Fatalln("Parameter slack.keyword is required") } if viper.GetString("config.path") != "" { viper.AddConfigPath(viper.GetString("config.path")) @@ -68,20 +70,29 @@ func main() { rtm = client.NewRTM() ) - go rtm.ManageConnection() log.Infoln("Send request for RTM connection") + go rtm.ManageConnection() var incomingErrorCount = 0 for packet := range rtm.IncomingEvents { - log.Printf("Incoming event with type %s", packet.Type) + log.Debugf("Incoming event with type %s", packet.Type) + switch event := packet.Data.(type) { + case *slack.ConnectedEvent: + log.Infoln("RTM connection established") + + case *slack.InvalidAuthEvent: + rtm.Disconnect() + log.Fatalf("Could not authenticate, invalid Slack token passed, terminate") + case *slack.IncomingEventError: incomingErrorCount++ + log.Warningf("RTM incoming error: %+v", event.Error()) if incomingErrorCount >= incomingErrorRetry { - log.Fatalf("Reached error reconnect limit %d, terminate", incomingErrorRetry) + rtm.Disconnect() + log.Fatalf("Reached error reconnect limit %d on %s type error, terminate", incomingErrorRetry, packet.Type) } - case *slack.ConnectedEvent: - log.Infoln("RTM connection established") + case *slack.MessageEvent: log.Printf("Incoming message event") if err := handleMessageEvent(rtm, event); err != nil { @@ -97,7 +108,7 @@ func handleMessageEvent(rtm *slack.RTM, event *slack.MessageEvent) error { return fmt.Errorf("incoming message with empty text") } // check keywords - var keywords = viper.GetStringSlice("slack.keywords") + var keywords = viper.GetStringSlice("slack.keyword") contains := any(keywords, func(keyword string) bool { return strings.Contains(strings.ToLower(event.Text), strings.ToLower(keyword)) }) From c9349bdf7b025df43c96a6f3a923523e3a7900e1 Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Tue, 1 May 2018 00:38:54 +0300 Subject: [PATCH 4/9] Add docker support Update makefile Fix parsing env variables Fix config path parameter usage Update README.md --- .docker/Dockerfile | 12 ++++++++++++ .docker/docker-entrypoint | 9 +++++++++ .dockerignore | 3 +++ Makefile | 10 ++++++++-- README.md | 21 ++++++++++++++------- main.go | 28 ++++++++++++---------------- 6 files changed, 58 insertions(+), 25 deletions(-) create mode 100644 .docker/Dockerfile create mode 100755 .docker/docker-entrypoint diff --git a/.docker/Dockerfile b/.docker/Dockerfile new file mode 100644 index 0000000..3af30e5 --- /dev/null +++ b/.docker/Dockerfile @@ -0,0 +1,12 @@ +FROM alpine:3.7 + +ARG APP + +COPY "${APP}" /usr/local/bin/slack-duty-bot +COPY .docker/docker-entrypoint /usr/local/bin/ + +RUN apk --no-cache add ca-certificates + +ENTRYPOINT ["docker-entrypoint"] + +CMD ["slack-duty-bot"] diff --git a/.docker/docker-entrypoint b/.docker/docker-entrypoint new file mode 100755 index 0000000..c77389c --- /dev/null +++ b/.docker/docker-entrypoint @@ -0,0 +1,9 @@ +#!/bin/sh + +set -e + +if [ "${1#-}" != "$1" ]; then + set -- slack-duty-bot "$@" +fi + +exec "$@" diff --git a/.dockerignore b/.dockerignore index b547f76..5ff03c1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,6 @@ Gopkg* Makefile LICENSE +/vendor +/idea +config.yaml diff --git a/Makefile b/Makefile index f48ff87..47f3be5 100644 --- a/Makefile +++ b/Makefile @@ -29,11 +29,17 @@ build: clean dep-ensure container: build docker rmi ${DOCKER_IMAGE}:${DOCKER_TAG} || true - docker build --build-arg APP=${APP} -f .docker/Dockerfile -t ${DOCKER_IMAGE}:${DOCKER_TAG} . + docker build \ + --build-arg APP=${APP} \ + -f .docker/Dockerfile \ + -t ${DOCKER_IMAGE}:${DOCKER_TAG} \ + . run: container docker stop ${APP} || true && docker rm ${APP} || true - docker run --name ${APP} --rm \ + docker run \ + --name ${APP} \ + --rm \ -e SDB_SLACK_TOKEN=${SDB_SLACK_TOKEN} \ ${DOCKER_IMAGE}:${DOCKER_TAG} \ --slack.keyword ${SDB_SLACK_KEYWORD} diff --git a/README.md b/README.md index f148810..7b7bc16 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Go Report Card](https://goreportcard.com/badge/github.com/iqoption/slack-duty-bot)](https://goreportcard.com/report/github.com/iqoption/slack-duty-bot) -### How usage +### Usage 1. Create new custom integration `Bots` (e.g https://{team}.slack.com/apps/manage/custom-integrations) +2. Add bot to channels you want to listen 2. Build for your environment 3. Prepare config.yaml with duties list 3. Run with the required parameters ```bash SDB_SLACK_TOKEN=your-token-here ./slack-duty-bot \ - --slack.keywords keyword-1 \ - --slack.keywords keyword-2 \ + --slack.keyword keyword-1 \ + --slack.keyword keyword-2 \ --slack.group.id your-group-id \ --slack.group.name your-group-name ``` @@ -32,10 +33,11 @@ Build in docker docker run --rm -v $(pwd):/go/src/slack-duty-bot -w /go/src/slack-duty-bot golang:1.10 make BUILD_OS=linux BUILD_ARCH=amd64 ``` -### Configuration flags, environment variables +### Configuration + +#### Configuration flags, environment variables Environment variables are prefixed with `SDB_` and *MUST* be uppercase with `_` delimiter Available variables: -* `SDB_CONFIG_PATH` * `SDB_SLACK_TOKEN` * `SDB_SLACK_GROUP_ID` * `SDB_SLACK_GROUP_NAME` @@ -52,8 +54,8 @@ Available flags: You can get IDS from api or just use [testing page](https://api.slack.com/methods/usergroups.list/test) -### Configuration file -Configuration file *MUST* contain `duties` key with *7* slices of Slack username +#### Configuration file +Configuration file *MUST* contain `duties` key with *7* slices of Slack user names ```yaml duties: - [username.one, username.two] # Sunday @@ -64,3 +66,8 @@ duties: - [username.one] # Friday - [username.one, username.two] # Saturday ``` + +#### Configuration priority +* Flags +* Environment variables +* Config file diff --git a/main.go b/main.go index 7390967..e46c471 100644 --- a/main.go +++ b/main.go @@ -16,8 +16,6 @@ const ( ) func init() { - viper.SetEnvPrefix("SDB") - pflag.String("config.path", "", "Config path") pflag.String("slack.token", "", "Slack API client token config") // We need ID and name only because bot users can't read user groups info via api @@ -28,27 +26,30 @@ func init() { viper.BindPFlags(pflag.CommandLine) viper.AutomaticEnv() - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) - viper.BindEnv("config.path") - viper.BindEnv("slack.token") - viper.BindEnv("slack.keyword") - viper.BindEnv("slack.group.id") - viper.BindEnv("slack.group.name") - viper.BindEnv("slack.threads") + viper.SetEnvPrefix("SDB") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", ".", "_", "_")) + viper.BindEnv("slack_token") + viper.BindEnv("slack_group_id") + viper.BindEnv("slack_group_name") + viper.BindEnv("slack_threads") viper.SetConfigType("yaml") viper.AddConfigPath("$HOME/.slack-duty-bot") viper.AddConfigPath(".") - viper.SetDefault("config.path", "/etc/slack-duty-bot") - log.SetFormatter(&log.TextFormatter{DisableColors: true}) log.SetLevel(log.DebugLevel) } func main() { pflag.Parse() + + viper.AddConfigPath(viper.GetString("config.path")) viper.ReadInConfig() + viper.WatchConfig() + viper.OnConfigChange(func(e fsnotify.Event) { + log.Infoln("Config file was changed") + }) if viper.GetString("slack.token") == "" { log.Fatalln("Parameter slack.token is required") @@ -60,11 +61,6 @@ func main() { viper.AddConfigPath(viper.GetString("config.path")) } - viper.WatchConfig() - viper.OnConfigChange(func(e fsnotify.Event) { - log.Infoln("Config file was changed") - }) - var ( client = slack.New(viper.GetString("slack.token")) rtm = client.NewRTM() From ca3851f38dd12f1d385ff77b5f67189fd277b47e Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Tue, 1 May 2018 01:01:08 +0300 Subject: [PATCH 5/9] Add info how to run in docker --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 7b7bc16..b22d2ce 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,22 @@ SDB_SLACK_TOKEN=your-token-here ./slack-duty-bot \ --slack.group.name your-group-name ``` +You also can run in application in docker +```bash +docker run \ + --name slack-duty-bot \ + --restart on-failure \ + -v $(pwd)/config:/etc/slack-duty-bot \ + -e SDB_SLACK_TOKEN=your-token-here \ + -d \ + insidieux/slack-duty-bot:1.0.0 \ + --config.path=/etc/slack-duty-bot \ + --slack.keyword keyword-1 \ + --slack.keyword keyword-2 + --slack.group.id your-group-id \ + --slack.group.name your-group-name \ +``` + ### Build package Build ```bash From 0e0e09ad9af9bca3538bc79926175f4ad23369f4 Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Wed, 2 May 2018 01:38:02 +0300 Subject: [PATCH 6/9] Add Kubernetes support (simple deployment) Fix working with env variables Update README and ROADMAP --- .gitignore | 1 + .kubernetes/deploy.yaml.tpl | 69 +++++++++++++++++++++++++++++++ CHANGELOG.md | 2 + README.md | 82 +++++++++++++++++++++++++++++++++++-- ROADMAP.md | 6 ++- main.go | 24 +++++------ 6 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 .kubernetes/deploy.yaml.tpl diff --git a/.gitignore b/.gitignore index c1e3575..61898ec 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ /vendor slack-duty-bot config.yaml +.kubernetes/deploy.yaml diff --git a/.kubernetes/deploy.yaml.tpl b/.kubernetes/deploy.yaml.tpl new file mode 100644 index 0000000..f35459f --- /dev/null +++ b/.kubernetes/deploy.yaml.tpl @@ -0,0 +1,69 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: slack-duty-bot-${SDB_NAME}-secret +type: Opaque +data: + token: ${SDB_SLACK_TOKEN_BASE64} +--- + +kind: ConfigMap +apiVersion: v1 +metadata: + name: slack-duty-bot-${SDB_NAME}-config-map +data: + config.yaml: |- + slack: + keyword: + - ${SDB_KEYWORD} + group: + id: ${SDB_SLACK_GROUP_ID} + name: ${SDB_SLACK_GROUP_NAME} + duties: + - [${SDB_SLACK_DEFAULT_USER}] + - [${SDB_SLACK_DEFAULT_USER}] + - [${SDB_SLACK_DEFAULT_USER}] + - [${SDB_SLACK_DEFAULT_USER}] + - [${SDB_SLACK_DEFAULT_USER}] + - [${SDB_SLACK_DEFAULT_USER}] + - [${SDB_SLACK_DEFAULT_USER}] +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: slack-duty-bot-${SDB_NAME} + labels: + app: slack-duty-bot-${SDB_NAME} +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 50% + maxSurge: 1 + template: + metadata: + labels: + app: slack-duty-bot-${SDB_NAME} + spec: + containers: + - name: slack-duty-bot-${SDB_NAME} + image: insidieux/slack-duty-bot:${SDB_TAG} + imagePullPolicy: Always + args: ["--config.path=/etc/slack-duty-bot"] + env: + - name: SDB_SLACK_TOKEN + valueFrom: + secretKeyRef: + name: slack-duty-bot-${SDB_NAME}-secret + key: token + volumeMounts: + - name: slack-duty-bot-${SDB_NAME}-config-volume + mountPath: /etc/slack-duty-bot + volumes: + - name: slack-duty-bot-${SDB_NAME}-config-volume + configMap: + name: slack-duty-bot-${SDB_NAME}-config-map + restartPolicy: Always + terminationGracePeriodSeconds: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 483e244..265c2e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ CHANGELOG * Using Slack RTM * Configuration with config file, environment variables and flags * Live reload config file + * Docker image + * Simple Kubernetes deploy Init ---- diff --git a/README.md b/README.md index b22d2ce..9017dfe 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ docker run --rm -v $(pwd):/go/src/slack-duty-bot -w /go/src/slack-duty-bot golan ### Configuration #### Configuration flags, environment variables -Environment variables are prefixed with `SDB_` and *MUST* be uppercase with `_` delimiter +Environment variables are prefixed with `SDB_` and **MUST** be uppercase with `_` delimiter Available variables: * `SDB_SLACK_TOKEN` * `SDB_SLACK_GROUP_ID` @@ -61,7 +61,7 @@ Available variables: Every environment variable can be overwritten by startup flags Available flags: -* `--config.path` - path to yml config (default: . and $HOME/.slack-duty-bot) +* `--config.path` - path to config.yaml file (default: . and $HOME/.slack-duty-bot) * `--slack.token` - Slack API client token * `--slack.keyword` - Case insensitive keywords slice to search in message text, can be set multiple times (default: []) * `--slack.group.name` - Slack user group name, to mention in channel if duty list is empty @@ -71,7 +71,7 @@ Available flags: You can get IDS from api or just use [testing page](https://api.slack.com/methods/usergroups.list/test) #### Configuration file -Configuration file *MUST* contain `duties` key with *7* slices of Slack user names +Configuration file **MUST** contain `duties` key with **7** slices of Slack user names ```yaml duties: - [username.one, username.two] # Sunday @@ -87,3 +87,79 @@ duties: * Flags * Environment variables * Config file + +### Deploy to Kubernetes + +#### Create namespace +```bash +kubectl create namespace slack-duty-bot +``` + +#### Create namespace quota +```yaml +#namespace-quota.yaml +apiVersion: v1 +kind: ResourceQuota +metadata: + name: slack-duty-bot-quota +spec: + hard: + requests.cpu: "2" + requests.memory: 1Gi + limits.cpu: "4" + limits.memory: 2Gi +``` +```bash +kubectl create -f namespace-quota.yaml --namespace=slack-duty-bot +``` + +#### Create limit range +```yaml +#namespace-limit-range.yaml +apiVersion: v1 +kind: LimitRange +metadata: + name: slack-duty-bot-limit-range +spec: +limits: + - default: + cpu: "200m" + memory: 128Mi + defaultRequest: + cpu: "100m" + memory: 64Mi + type: Container +``` +```bash +kubectl create -f namespace-limit-range.yaml --namespace=slack-duty-bot +``` + +#### Prepare your deployment file +```bash +(docker run \ + --rm \ + -it \ + -v $(pwd):/tmp \ + -e SDB_SLACK_TOKEN_BASE64=your-token-hash \ + -e SDB_NAME=your-deployment-name \ + -e SDB_TAG=1.0.0 \ + -e SDB_KEYWORD=your-keyword \ + -e SDB_SLACK_DEFAULT_USER=default-username \ + -e SDB_SLACK_GROUP_ID=group-id \ + -e SDB_SLACK_GROUP_NAME=group-name \ + supinf/envsubst /tmp/.kubernetes/deploy.yaml.tpl) > $(pwd)/.kubernetes/deploy.yaml + +``` +or use native `envsubst` +```bash +(SDB_NAME=bot-name SDB_TAG=1.0.0 envsubst < $(pwd)/.kubernetes/deploy.yaml.tpl) $(pwd)/.kubernetes/deploy.yaml +``` + +#### Deploy! +```bash +kubectl apply -f $(pwd)/.kubernetes/deploy.yaml --namespace slack-duty-bot +``` + +## Authors +* [Konstantin Perminov](https://github.com/SpiLLeR) +* [Ageev Pavel](https://github.com/insidieux) diff --git a/ROADMAP.md b/ROADMAP.md index ee0e280..3908d45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,9 @@ -CHANGELOG -========= +ROADMAP +======== +* Tests * Signal handlers * HTTP rest API for edit duties configuration * Persistent storage for duties configuration * Multiple instance mode with ex-locks +* HELM support diff --git a/main.go b/main.go index e46c471..e00df56 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,18 @@ const ( ) func init() { + viper.SetConfigType("yaml") + viper.AddConfigPath("$HOME/.slack-duty-bot") + viper.AddConfigPath(".") + + viper.SetEnvPrefix("SDB") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + viper.BindEnv("slack.token") + viper.BindEnv("slack.group.id") + viper.BindEnv("slack.group.name") + viper.BindEnv("slack.threads") + viper.AutomaticEnv() + pflag.String("config.path", "", "Config path") pflag.String("slack.token", "", "Slack API client token config") // We need ID and name only because bot users can't read user groups info via api @@ -25,18 +37,6 @@ func init() { pflag.Bool("slack.threads", true, "Usage of Slack threads to reply on messages") viper.BindPFlags(pflag.CommandLine) - viper.AutomaticEnv() - viper.SetEnvPrefix("SDB") - viper.SetEnvKeyReplacer(strings.NewReplacer("-", ".", "_", "_")) - viper.BindEnv("slack_token") - viper.BindEnv("slack_group_id") - viper.BindEnv("slack_group_name") - viper.BindEnv("slack_threads") - - viper.SetConfigType("yaml") - viper.AddConfigPath("$HOME/.slack-duty-bot") - viper.AddConfigPath(".") - log.SetFormatter(&log.TextFormatter{DisableColors: true}) log.SetLevel(log.DebugLevel) } From a7747bc21b1075ee5c15245a28dfa5db6934013b Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Wed, 2 May 2018 01:52:32 +0300 Subject: [PATCH 7/9] Remove debug message on incoming event --- main.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/main.go b/main.go index e00df56..55c129c 100644 --- a/main.go +++ b/main.go @@ -71,8 +71,6 @@ func main() { var incomingErrorCount = 0 for packet := range rtm.IncomingEvents { - log.Debugf("Incoming event with type %s", packet.Type) - switch event := packet.Data.(type) { case *slack.ConnectedEvent: log.Infoln("RTM connection established") From 54489955f23bbd6d943a92d1ddd3526c6a9c0873 Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Wed, 2 May 2018 10:20:04 +0300 Subject: [PATCH 8/9] Remove message text if not suitable keywords --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 55c129c..fb7d4f1 100644 --- a/main.go +++ b/main.go @@ -107,7 +107,7 @@ func handleMessageEvent(rtm *slack.RTM, event *slack.MessageEvent) error { return strings.Contains(strings.ToLower(event.Text), strings.ToLower(keyword)) }) if contains == false { - return fmt.Errorf("incoming message text '%s' does not contain any suitable keywords (%s)", event.Text, strings.Join(keywords, ", ")) + return fmt.Errorf("incoming message text does not contain any suitable keywords (%s)", strings.Join(keywords, ", ")) } log.Infof("Incoming message text: %s", event.Text) // collection user ids for make duties list From 2ac1f484b759eac91f3fb2dd9e960e16c7791bd0 Mon Sep 17 00:00:00 2001 From: Pavel Ageev Date: Wed, 2 May 2018 12:11:20 +0300 Subject: [PATCH 9/9] Update info about native envsubst --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9017dfe..1081ecb 100644 --- a/README.md +++ b/README.md @@ -152,9 +152,18 @@ kubectl create -f namespace-limit-range.yaml --namespace=slack-duty-bot ``` or use native `envsubst` ```bash -(SDB_NAME=bot-name SDB_TAG=1.0.0 envsubst < $(pwd)/.kubernetes/deploy.yaml.tpl) $(pwd)/.kubernetes/deploy.yaml +(SDB_SLACK_TOKEN_BASE64=your-token-hash \ + SDB_NAME=your-deployment-name \ + SDB_TAG=1.0.0 \ + SDB_KEYWORD=your-keyword \ + SDB_SLACK_DEFAULT_USER=default-username \ + SDB_SLACK_GROUP_ID=group-id \ + SDB_SLACK_GROUP_NAME=group-name \ + envsubst < $(pwd)/.kubernetes/deploy.yaml.tpl) $(pwd)/.kubernetes/deploy.yaml ``` +After that you can change configuration with `kubect` or edit config map directly from Kubernetes dashboard + #### Deploy! ```bash kubectl apply -f $(pwd)/.kubernetes/deploy.yaml --namespace slack-duty-bot