From b5782a3d30bca226e9dace76315afe4f567e0b56 Mon Sep 17 00:00:00 2001 From: Ian Fijolek Date: Tue, 2 Feb 2021 11:57:39 -0800 Subject: [PATCH] Add ability to support multiple workspaces Adds a new perm to allow fetching the workspace domain --- Makefile | 5 ++ README.md | 31 +++++++----- auth.go | 22 +------- config.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 146 +++++++++++++++++++++++++++++------------------------- util.go | 20 ++++++++ 6 files changed, 260 insertions(+), 100 deletions(-) create mode 100644 config.go create mode 100644 util.go diff --git a/Makefile b/Makefile index d09befb..46a29ae 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ all: dist .PHONY: test test: + pre-commit run --all-files go test slack-status: $(GOFILES) @@ -34,3 +35,7 @@ $(DIST_TARGETS): $(GOFILES) clean: rm ./slack-status rm -fr ./dist + +.PHONY: install-hooks +install-hooks: + pre-commit install --overwrite --install-hooks diff --git a/README.md b/README.md index 18347e6..40634fd 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,20 @@ Here's how to do that on [Firefox](https://support.mozilla.org/en-US/kb/error-co ## Example usage -Set auth token (it will store it in `~/.config/slack-status-cli` or your `$XDG_CONFIG_HOME` dir +Login (it will store it in `~/.config/slack-status-cli` or your `$XDG_CONFIG_HOME` dir - slack-status -auth-token + slack-status -login + slack-status -login -domain custom-workspace-name + slack-status -make-default -domain new-default-workspace + +Specify domain to use + + # Use the deafult domain + slack-status :face_with_cowboy_hat: Howdy partner + # Specify domain for this status + slack-status -domain my-workspace :face_with_cowboy_hat: Howdy partner + # Specify domain for this status and make it the new default + slack-status -domain my-workspace -make-default :face_with_cowboy_hat: Howdy partner Set status without emoji @@ -29,25 +40,25 @@ Set status without emoji Set status with emoji slack-status :walking-the-dog: Walking the dog - slack-status --emoji :walking-the-dog: Walking the dog + slack-status -emoji :walking-the-dog: Walking the dog Set status with duration (eg. `10m`, `2h`, `7d12h`) slack-status 10m :walking-the-dog: Walking the dog slack-status :walking-the-dog: Walking the dog for 10m - slack-status --duration 10m --emoji :walking-the-dog: Walking the dog + slack-status -duration 10m -emoji :walking-the-dog: Walking the dog Set status with duration and snooze notifications - slack-status --snooze --duration 12h --emoji :bed: Good night - slack-status --snooze :bed: Good night for 12h + slack-status -snooze -duration 12h -emoji :bed: Good night + slack-status -snooze :bed: Good night for 12h Set a status that contains a duration # Set status to "On a break" for 5 minutes slack-status :sleeping: On a break for 5m # Set status to "On a break for 5m" for 5 minutes - slack-status --duration 5m :sleeping: On a break for 5m + slack-status -duration 5m :sleeping: On a break for 5m # Set status to "On a break for 5m" with no duration slack-status :sleeping: "On a break for 5m" @@ -57,8 +68,4 @@ Clear existing status and snooze durations Snooze notifications without updating your status - slack-status --duration 15m --snooze - -## Future - -I plan to do a bit of work to bundle this for easier distribution and maybe support multiple workspaces. + slack-status -duration 15m -snooze diff --git a/auth.go b/auth.go index d8d2e22..f12374e 100644 --- a/auth.go +++ b/auth.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "net/http" - "os" "os/exec" "strings" "time" @@ -20,15 +19,6 @@ var ( defaultClientSecret = "" ) -func getEnvOrDefault(name, defaultValue string) string { - val, ok := os.LookupEnv(name) - if ok { - return val - } - - return defaultValue -} - type slackApp struct { clientID, clientSecret, redirectURI string scopes, userScopes []string @@ -94,14 +84,6 @@ func (app slackApp) listenForCode() (string, error) { return code, nil } -func fileExists(path string) bool { - if _, err := os.Stat(path); os.IsNotExist(err) { - return false - } - - return true -} - func generateSelfSignedCertificates(certPath, keyPath string) error { command := exec.Command( "openssl", @@ -125,8 +107,8 @@ func generateSelfSignedCertificates(certPath, keyPath string) error { func authenticate() (string, error) { app := slackApp{ - userScopes: []string{"dnd:write", "users.profile:write"}, - scopes: []string{"dnd:write", "users.profile:write"}, + userScopes: []string{"dnd:write", "users.profile:write", "team:read"}, + scopes: []string{"dnd:write", "users.profile:write", "team:read"}, clientID: getEnvOrDefault("CLIENT_ID", defaultClientID), clientSecret: getEnvOrDefault("CLIENT_SECRET", defaultClientSecret), redirectURI: "https://localhost:8888/auth", diff --git a/config.go b/config.go new file mode 100644 index 0000000..a2676f6 --- /dev/null +++ b/config.go @@ -0,0 +1,136 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" +) + +var ( + errUnknownDomain = errors.New("unknown domain") +) + +type configData struct { + DefaultDomain string + DomainTokens map[string]string +} + +// getConfigFilePath returns the path of a given file within the config folder. +// The config folder will be created in ~/.local/config/slack-status-cli if it does not exist. +func getConfigFilePath(filename string) string { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + configHome = "~/.local/config" + } + + configDir := filepath.Join(configHome, "slack-status-cli") + _ = os.MkdirAll(configDir, 0755) + + return filepath.Join(configDir, filename) +} + +// readConfig returns the current configuration +func readConfig() (*configData, error) { + configPath := getConfigFilePath("config.json") + + if !fileExists(configPath) { + return &configData{DomainTokens: map[string]string{}}, nil + } + + content, err := ioutil.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("error reading config from file: %w", err) + } + + var config configData + + err = json.Unmarshal(content, &config) + if err != nil { + return nil, fmt.Errorf("failed parsing json from config file: %w", err) + } + + return &config, nil +} + +// writeConfig writes the provided config data +func writeConfig(config configData) error { + configPath := getConfigFilePath("config.json") + + contents, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed converting config to json: %w", err) + } + + if err = ioutil.WriteFile(configPath, contents, 0600); err != nil { + return fmt.Errorf("error writing config to file: %w", err) + } + + return nil +} + +// getDefaultLogin returns the default login from the configuration +func getDefaultLogin() (string, error) { + config, err := readConfig() + if err != nil { + return "", err + } + + accessToken, exists := config.DomainTokens[config.DefaultDomain] + if !exists { + return "", errUnknownDomain + } + + return accessToken, nil +} + +// getLogin returns the token for a specified login domain +func getLogin(domain string) (string, error) { + config, err := readConfig() + if err != nil { + return "", err + } + + accessToken, exists := config.DomainTokens[domain] + if !exists { + return "", errUnknownDomain + } + + return accessToken, nil +} + +// saveLogin writes the provided token to the provided domain +func saveLogin(domain, accessToken string) error { + config, err := readConfig() + if err != nil { + return err + } + + config.DomainTokens[domain] = accessToken + + // If this is the only domain, make it default + if len(config.DomainTokens) == 1 { + config.DefaultDomain = domain + } + + return writeConfig(*config) +} + +// saveDefaultLogin saves the specified domain as the default +func saveDefaultLogin(domain string) error { + config, err := readConfig() + if err != nil { + return err + } + + _, exists := config.DomainTokens[domain] + if !exists { + return fmt.Errorf("cannot set domain to default: %w", errUnknownDomain) + } + + config.DefaultDomain = domain + + return writeConfig(*config) +} diff --git a/main.go b/main.go index a21511d..59f7be4 100644 --- a/main.go +++ b/main.go @@ -3,10 +3,7 @@ package main import ( "flag" "fmt" - "io/ioutil" "log" - "os" - "path/filepath" "strings" "time" @@ -15,10 +12,14 @@ import ( // statusInfo contains all args passed from the command line type statusInfo struct { + // status contents emoji, statusText string duration time.Duration snooze bool - accessToken string + + // domain and login management + login, makeDefault bool + domain string } // getExipirationTime returns epoch time that status should expire from the duration. @@ -30,43 +31,6 @@ func (si statusInfo) getExpirationTime() int64 { return time.Now().Add(si.duration).Unix() } -// getConfigFilePath returns the path of a given file within the config folder. -// The config folder will be created in ~/.local/config/slack-status-cli if it does not exist. -func getConfigFilePath(filename string) string { - configHome := os.Getenv("XDG_CONFIG_HOME") - if configHome == "" { - configHome = "~/.local/config" - } - - configDir := filepath.Join(configHome, "slack-status-cli") - _ = os.MkdirAll(configDir, 0755) - - return filepath.Join(configDir, filename) -} - -// writeAccessToken writes the access token to a file for future use. -func writeAccessToken(accessToken string) error { - tokenFile := getConfigFilePath("token") - - if err := ioutil.WriteFile(tokenFile, []byte(accessToken), 0600); err != nil { - return fmt.Errorf("error writing access token %w", err) - } - - return nil -} - -// readAccessToken retreive access token from a file -func readAccessToken() (string, error) { - tokenFile := getConfigFilePath("token") - - content, err := ioutil.ReadFile(tokenFile) - if err != nil { - return "", fmt.Errorf("error reading access token from file %w", err) - } - - return string(content), nil -} - // readDurationArgs will attempt to find a duration within command line args rather than flags. // It will look for a prefixed duration. eg. "5m :cowboy: Howdy y'all" and a postfix duration // following the word "for". eg. ":dancing: Dancing for 1h". @@ -104,10 +68,15 @@ func readDurationArgs(args []string) ([]string, *time.Duration) { // readFlags will read all flags off the command line. func readFlags() statusInfo { + // Non-status flags + login := flag.Bool("login", false, "login to a Slack workspace") + domain := flag.String("domain", "", "domain to set status on") + makeDefault := flag.Bool("make-default", false, "set the current domain to default") + + // Status flags snooze := flag.Bool("snooze", false, "snooze notifications") duration := flag.Duration("duration", 0, "duration to set status for") emoji := flag.String("emoji", "", "emoji to use as status") - accessToken := flag.String("access-token", "", "slack access token") flag.Parse() @@ -134,48 +103,89 @@ func readFlags() statusInfo { statusText := strings.Join(args, " ") return statusInfo{ - duration: *duration, - snooze: *snooze, - emoji: *emoji, - accessToken: *accessToken, - statusText: statusText, + duration: *duration, + snooze: *snooze, + emoji: *emoji, + statusText: statusText, + + login: *login, + domain: *domain, + makeDefault: *makeDefault, } } -func getAccessToken(accessToken string) (string, error) { - // If provided, save and return - if accessToken != "" { - return accessToken, writeAccessToken(accessToken) +// loginAndSave will return a client after a new login flow and save the results +func loginAndSave(domain string) (*slack.Client, error) { + accessToken, err := authenticate() + if err != nil { + return nil, fmt.Errorf("failed to authenticate new login: %w", err) } - // Try to get from stored file - accessToken, err := readAccessToken() - if accessToken != "" && err == nil { - // Successfully read from file - return accessToken, nil + client := slack.New(accessToken) + + if domain == "" { + info, err := client.GetTeamInfo() + if err != nil { + return client, fmt.Errorf("failed to get team info: %w", err) + } + + domain = info.Domain } - // Begin auth process to fetch a new token - accessToken, err = authenticate() - - if err == nil { - // Successful authentication, save the token - err = writeAccessToken(accessToken) + err = saveLogin(domain, accessToken) + if err != nil { + return client, fmt.Errorf("failed saving new login info: %w", err) } - return accessToken, err + return client, err +} + +// getClient returns a client either via the provided login or default login +func getClient(domain string) (*slack.Client, error) { + var accessToken string + var err error + + if domain == "" { + accessToken, err = getDefaultLogin() + if err != nil { + return nil, fmt.Errorf("failed to get default login: %w", err) + } + } else { + accessToken, err = getLogin(domain) + if err != nil { + return nil, fmt.Errorf("failed to get login for domain %s: %w", domain, err) + } + } + + return slack.New(accessToken), nil } func main() { args := readFlags() - accessToken, err := getAccessToken(args.accessToken) - if err != nil { - fmt.Println("error getting access token") - log.Fatal(err) + var client *slack.Client + var err error + + // If the new-auth flag is present, force an auth flow + if args.login { + client, err = loginAndSave(args.domain) + } else { + client, err = getClient(args.domain) + } + + // We encountered some error in logging in + if err != nil { + fmt.Println("Unable to create Slack client. Have you logged in yet? Try using `-login`") + log.Fatal(fmt.Errorf("failed to get or save client: %w", err)) + } + + // If a domain is provided and asked to make deafult, save it to config + if args.makeDefault && args.domain != "" { + if err = saveDefaultLogin(args.domain); err != nil { + log.Fatal(fmt.Errorf("failed saving default domain %s: %w", args.domain, err)) + } } - client := slack.New(accessToken) err = client.SetUserCustomStatus(args.statusText, args.emoji, args.getExpirationTime()) if err != nil { fmt.Println("error setting status") diff --git a/util.go b/util.go new file mode 100644 index 0000000..ef081dd --- /dev/null +++ b/util.go @@ -0,0 +1,20 @@ +package main + +import "os" + +func getEnvOrDefault(name, defaultValue string) string { + val, ok := os.LookupEnv(name) + if ok { + return val + } + + return defaultValue +} + +func fileExists(path string) bool { + if _, err := os.Stat(path); os.IsNotExist(err) { + return false + } + + return true +}