From cb135e984269739353f40749b2c607d472c93f39 Mon Sep 17 00:00:00 2001 From: Vincent Composieux Date: Tue, 30 Jan 2018 11:09:32 +0100 Subject: [PATCH] Initialization --- .env.dist | 5 ++ .gitignore | 3 + Gopkg.lock | 97 ++++++++++++++++++++++++++++ Gopkg.toml | 50 +++++++++++++++ LICENSE | 21 ++++++ Makefile | 32 ++++++++++ README.md | 53 +++++++++++++++ coach/coach.go | 129 +++++++++++++++++++++++++++++++++++++ config/config.go | 18 ++++++ config/elevenbot.service | 16 +++++ google/api.go | 135 +++++++++++++++++++++++++++++++++++++++ main.go | 29 +++++++++ 12 files changed, 588 insertions(+) create mode 100644 .env.dist create mode 100644 .gitignore create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 coach/coach.go create mode 100644 config/config.go create mode 100644 config/elevenbot.service create mode 100644 google/api.go create mode 100644 main.go 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 {} +}