diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..b9ca82d --- /dev/null +++ b/.env.dist @@ -0,0 +1,5 @@ +ELEVENBOT_COACH_SPREADSHEET_ID= +ELEVENBOT_SLACK_TOKEN= + +ELEVENBOT_SSH_USER= +ELEVENBOT_SSH_IP= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd806ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/config/client_secret.json +/.env +/vendor \ No newline at end of file diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..a442d0f --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,97 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "cloud.google.com/go" + packages = ["compute/metadata"] + revision = "767c40d6a2e058483c25fa193e963a22da17236d" + version = "v0.18.0" + +[[projects]] + branch = "master" + name = "github.com/golang/protobuf" + packages = ["proto"] + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + +[[projects]] + name = "github.com/robfig/cron" + packages = ["."] + revision = "b024fc5ea0e34bc3f83d9941c8d60b0622bfaca4" + version = "v1" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "d682213848ed68c0a260ca37d6dd5ace8423f5ba" + version = "v1.0.4" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "1875d0a70c90e57f11972aefd42276df65e895b9" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "context/ctxhttp" + ] + revision = "0ed95abb35c445290478a5348a7b38bb154135fd" + +[[projects]] + branch = "master" + name = "golang.org/x/oauth2" + packages = [ + ".", + "google", + "internal", + "jws", + "jwt" + ] + revision = "a032972e28060ca4f5644acffae3dfc268cc09db" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "3dbebcf8efb6a5011a60c2b4591c1022a759af8a" + +[[projects]] + branch = "master" + name = "google.golang.org/api" + packages = [ + "gensupport", + "googleapi", + "googleapi/internal/uritemplates", + "sheets/v4" + ] + revision = "7d0e2d350555821bef5a5b8aecf0d12cc1def633" + +[[projects]] + name = "google.golang.org/appengine" + packages = [ + ".", + "internal", + "internal/app_identity", + "internal/base", + "internal/datastore", + "internal/log", + "internal/modules", + "internal/remote_api", + "internal/urlfetch", + "urlfetch" + ] + revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" + version = "v1.0.0" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "bb240098f1733b4767e12cb3006e2517be8749e430a4d0d2258365d6baef4e37" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..e55c4be --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,50 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# 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/sirupsen/logrus" + version = "1.0.4" + +[[constraint]] + branch = "master" + name = "golang.org/x/net" + +[[constraint]] + branch = "master" + name = "golang.org/x/oauth2" + +[[constraint]] + branch = "master" + name = "google.golang.org/api" + +[prune] + go-tests = true + unused-packages = true + +[[constraint]] + name = "github.com/robfig/cron" + version = "1.0.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8f2315 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Rohan Chandra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ff9741 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: help + +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +build: ## Build binary + GOOS=linux GOARCH=amd64 go build -o elevenbot main.go + +deploy: ## Deploys binary on a Google Cloud instance + @echo '>>> Deploy started' + + ssh ${ELEVENBOT_SSH_USER}@${ELEVENBOT_SSH_IP} 'sudo systemctl stop elevenbot' + scp elevenbot ${ELEVENBOT_SSH_USER}@${ELEVENBOT_SSH_IP}:~ + ssh ${ELEVENBOT_SSH_USER}@${ELEVENBOT_SSH_IP} 'sudo systemctl start elevenbot' + + @echo '>>> done!' + +prepare-deploy: ## Prepares bot to be deployed on a new instance + @echo '>>> Uploads config/ to server home directory' + + scp config/ ${ELEVENBOT_SSH_USER}@${ELEVENBOT_SSH_IP}:~ + ssh ${ELEVENBOT_SSH_USER}@${ELEVENBOT_SSH_IP} 'sudo mv config/elevenbot.service /etc/systemd/system/elevenbot.service' + ssh ${ELEVENBOT_SSH_USER}@${ELEVENBOT_SSH_IP} 'sudo systemctl daemon-reload' + + @echo '>>> done!' + +install: ## Installs dependencies + @echo '>>> Installs golang dependencies' + + dep ensure + + @echo '>>> done!' diff --git a/README.md b/README.md new file mode 100644 index 0000000..624eb7a --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +Coaching - Slack bot +==================== + +# Pre-requisites + +You need to have `dep` tool installed to manage dependencies: [https://github.com/golang/dep](https://github.com/golang/dep). + +# Installation + +First, you need to install dependencies by running: + +``` +$ make install +``` + +Then, you have to create a `.env` file with your own values: + +``` +$ cp .env.dist .env + +$ source .env +``` + +You are almost ready to go! You need a last step: create a Google API OAuth 2.0 token: + +You have to follow the instructions located in the link below in order to generate OAuth 2.0 Client ID keys: [https://cloud.google.com/genomics/downloading-credentials-for-api-access](https://cloud.google.com/genomics/downloading-credentials-for-api-access) + +Once it's done, download the `client_secret.json` file from the interface and put it under `config/` folder. + +On the first launch of the bot, you will also have to authenticate using OAuth2 with Google APIs. Just click on the link it outputs. + +# Usage (on dev environment) + +``` +$ go run main.go +``` + +# Compilation + +Run `make build` to build the source into a single `elevenbot` binary file. + +# Prepare deployment on a new server + +On first time deployment, you have to: + +Edit your `.env` file in order to edit `ELEVENBOT_SSH_USER` and `ELEVENBOT_SSH_IP` environment variables. +Edit `config/elevenbot.service` file with environment variables and user name. + +Then, run `make prepare-deploy` to prepare your instance. + +# Deploy + +Just run `make deploy` \ No newline at end of file diff --git a/coach/coach.go b/coach/coach.go new file mode 100644 index 0000000..2503cab --- /dev/null +++ b/coach/coach.go @@ -0,0 +1,129 @@ +package coach + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/eko/slackbot" + log "github.com/sirupsen/logrus" + "google.golang.org/api/sheets/v4" + + "../config" +) + +var ( + spreadsheetID = config.Getenv("ELEVENBOT_COACH_SPREADSHEET_ID") + sheetRange = "Planning Officiel!A1:AX" + + columnLastname = 0 + columnFirstname = 1 + columnSlack = 2 + columnEntryDate = 3 + columnRecruiter = 4 + columnCommercial = 5 + columnManager = 6 + columnMeetingRecruiter = 7 + columnMeetingTechnicalFirstname = 8 + columnMeetingTechnicalSlack = 9 + + monthFrench string + monthMapping = map[int]string{ + 1: "Janvier", + 2: "Février", + 3: "Mars", + 4: "Avril", + 5: "Mai", + 6: "Juin", + 7: "Juillet", + 8: "Août", + 9: "Septembre", + 10: "Octobre", + 11: "Novembre", + 12: "Décembre", + } +) + +// NotifyPassedMeetings reads spreadsheet data and prepare messages to be sent. +func NotifyPassedMeetings(sheetsService *sheets.Service) { + resp, err := sheetsService.Spreadsheets.Values.Get(spreadsheetID, sheetRange).Do() + + if err != nil { + log.Fatalf("[coach] Unable to retrieve data from sheet. %v", err) + } + + if len(resp.Values) == 0 { + fmt.Print("[coach] No data found.") + return + } + + var remindedColumnKey int + + for key, row := range resp.Values { + if 0 == key { + remindedColumnKey = FindRemindedColumn(row) + } else { + information := row[remindedColumnKey].(string) + + if strings.Contains(information, "Bilan OKR") { + fmt.Print(information) + SendSlackNotification(row) + } + } + } +} + +// FindRemindedColumn returns the reminded column at current date (current month -2 months) +func FindRemindedColumn(row []interface{}) int { + var value int + + now := time.Now() + reminder := now.AddDate(0, -2, 0) // reminder set for 2 after meeting + + for key, column := range row { + monthInt, _ := strconv.Atoi(reminder.Format("01")) + monthFrench = monthMapping[monthInt] + + if column == fmt.Sprintf("%s %s", monthFrench, reminder.Format("2006")) { + value = key + break + } + } + + return value +} + +// SendSlackNotification sens notification to concerned user. +func SendSlackNotification(row []interface{}) { + var userId string + + usersResponse, _ := slackbot.ListUsers() + + for i := 0; i < len(usersResponse.Members); i++ { + if row[columnMeetingTechnicalSlack] == usersResponse.Members[i].Name { + userId = usersResponse.Members[i].ID + break + } + } + + channel := slackbot.Channel{User: userId} + slackbot.OpenIM(channel) + + log.WithFields(log.Fields{ + "date": time.Now().Format("2006-01-02"), + "coach": row[columnMeetingTechnicalSlack], + "concerned": fmt.Sprintf("%s %s", row[columnFirstname], row[columnLastname]), + }).Info("Slack message sent.") + + message := slackbot.Message{ + AsUser: true, + Channel: userId, + Text: fmt.Sprintf( + "Hello %s !\nEn %s tu as fait passer (avec %s) le bilan OKR de %s.\nIl est temps de prendre de ses nouvelles :g11rocket:", + row[columnMeetingTechnicalFirstname], monthFrench, row[columnMeetingRecruiter], fmt.Sprintf("%s %s", row[columnFirstname], row[columnLastname]), + ), + } + + slackbot.PostMessage(message) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..3d2e845 --- /dev/null +++ b/config/config.go @@ -0,0 +1,18 @@ +package config + +import ( + log "github.com/sirupsen/logrus" + "os" +) + +// Getenv is a proxy function from os.Getenv() but also logs in case of error. +func Getenv(key string) string { + value := os.Getenv(key) + + if value == "" { + log.Errorf("Unable to load environment variable '%s'", key) + os.Exit(1) + } + + return value +} diff --git a/config/elevenbot.service b/config/elevenbot.service new file mode 100644 index 0000000..609fc94 --- /dev/null +++ b/config/elevenbot.service @@ -0,0 +1,16 @@ +[Unit] +Description=A coach Slack bot +After=network.target + +[Service] +Type=simple +User=1000 +WorkingDirectory=/home// +ExecStart=/home//elevenbot +Restart=on-abort + +Environment=ELEVENBOT_COACH_SPREADSHEET_ID= +Environment=ELEVENBOT_SLACK_TOKEN= + +[Install] +WantedBy=multi-user.target diff --git a/google/api.go b/google/api.go new file mode 100644 index 0000000..df840bb --- /dev/null +++ b/google/api.go @@ -0,0 +1,135 @@ +package google + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "os/user" + "path/filepath" + + log "github.com/sirupsen/logrus" + "golang.org/x/net/context" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/sheets/v4" +) + +// GetSheetsService reads the coaching program spreadsheet and sends Slack +// notifications 2 months after a meeting has been done. +func GetSheetsService() *sheets.Service { + ctx := context.Background() + + b, err := ioutil.ReadFile("config/client_secret.json") + + if err != nil { + log.Fatalf("Unable to read client secret file: %v", err) + } + + config, err := google.ConfigFromJSON(b, "https://www.googleapis.com/auth/spreadsheets.readonly") + + if err != nil { + log.Fatalf("Unable to parse client secret file to config: %v", err) + } + + client := GetClient(ctx, config) + + srv, err := sheets.New(client) + + if err != nil { + log.Fatalf("Unable to retrieve Sheets Client %v", err) + } + + return srv +} + +// GetClient uses a Context and Config to retrieve a Token +// then generate a Client. It returns the generated Client. +func GetClient(ctx context.Context, config *oauth2.Config) *http.Client { + cacheFile, err := TokenCacheFile() + + if err != nil { + log.Fatalf("Unable to get path to cached credential file. %v", err) + } + + tok, err := TokenFromFile(cacheFile) + + if err != nil { + tok = GetTokenFromWeb(config) + SaveToken(cacheFile, tok) + } + + return config.Client(ctx, tok) +} + +// GetTokenFromWeb uses Config to request a Token. +// It returns the retrieved Token. +func GetTokenFromWeb(config *oauth2.Config) *oauth2.Token { + authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) + fmt.Printf("Go to the following link in your browser then type the "+ + "authorization code: \n%v\n", authURL) + + var code string + + if _, err := fmt.Scan(&code); err != nil { + log.Fatalf("Unable to read authorization code %v", err) + } + + tok, err := config.Exchange(oauth2.NoContext, code) + + if err != nil { + log.Fatalf("Unable to retrieve token from web %v", err) + } + + return tok +} + +// TokenCacheFile generates credential file path/filename. +// It returns the generated credential path/filename. +func TokenCacheFile() (string, error) { + usr, err := user.Current() + + if err != nil { + return "", err + } + + tokenCacheDir := filepath.Join(usr.HomeDir, ".credentials") + os.MkdirAll(tokenCacheDir, 0700) + + return filepath.Join(tokenCacheDir, + url.QueryEscape("sheets.googleapis.com-go-quickstart.json")), err +} + +// TokenFromFile retrieves a Token from a given file path. +// It returns the retrieved Token and any read error encountered. +func TokenFromFile(file string) (*oauth2.Token, error) { + f, err := os.Open(file) + + if err != nil { + return nil, err + } + + t := &oauth2.Token{} + err = json.NewDecoder(f).Decode(t) + + defer f.Close() + + return t, err +} + +// SaveToken uses a file path to create a file and store the +// token in it. +func SaveToken(file string, token *oauth2.Token) { + fmt.Printf("Saving credential file to: %s\n", file) + f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + + if err != nil { + log.Fatalf("Unable to cache oauth token: %v", err) + } + + defer f.Close() + + json.NewEncoder(f).Encode(token) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6863d7f --- /dev/null +++ b/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "github.com/eko/slackbot" + "github.com/robfig/cron" + log "github.com/sirupsen/logrus" + + "./coach" + "./config" + "./google" +) + +func main() { + log.Info("Bot is starting...") + slackbot.Token = config.Getenv("ELEVENBOT_SLACK_TOKEN") + + sheetsService := google.GetSheetsService() + + c := cron.New() + + // Every 1st day of the month at 09:00am (UTC so 11:00am in France) + c.AddFunc("0 0 9 1 * *", func() { + coach.NotifyPassedMeetings(sheetsService) + }) + + c.Start() + + select {} +}