Add ability to support multiple workspaces

Adds a new perm to allow fetching the workspace domain
This commit is contained in:
IamTheFij 2021-02-02 11:57:39 -08:00
parent 461ddafa58
commit b5782a3d30
6 changed files with 260 additions and 100 deletions

View File

@ -15,6 +15,7 @@ all: dist
.PHONY: test .PHONY: test
test: test:
pre-commit run --all-files
go test go test
slack-status: $(GOFILES) slack-status: $(GOFILES)
@ -34,3 +35,7 @@ $(DIST_TARGETS): $(GOFILES)
clean: clean:
rm ./slack-status rm ./slack-status
rm -fr ./dist rm -fr ./dist
.PHONY: install-hooks
install-hooks:
pre-commit install --overwrite --install-hooks

View File

@ -18,9 +18,20 @@ Here's how to do that on [Firefox](https://support.mozilla.org/en-US/kb/error-co
## Example usage ## 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 <your 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 Set status without emoji
@ -29,25 +40,25 @@ Set status without emoji
Set status with emoji Set status with emoji
slack-status :walking-the-dog: Walking the dog 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`) Set status with duration (eg. `10m`, `2h`, `7d12h`)
slack-status 10m :walking-the-dog: Walking the dog slack-status 10m :walking-the-dog: Walking the dog
slack-status :walking-the-dog: Walking the dog for 10m 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 Set status with duration and snooze notifications
slack-status --snooze --duration 12h --emoji :bed: Good night slack-status -snooze -duration 12h -emoji :bed: Good night
slack-status --snooze :bed: Good night for 12h slack-status -snooze :bed: Good night for 12h
Set a status that contains a duration Set a status that contains a duration
# Set status to "On a break" for 5 minutes # Set status to "On a break" for 5 minutes
slack-status :sleeping: On a break for 5m slack-status :sleeping: On a break for 5m
# Set status to "On a break for 5m" for 5 minutes # 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 # Set status to "On a break for 5m" with no duration
slack-status :sleeping: "On a break for 5m" slack-status :sleeping: "On a break for 5m"
@ -57,8 +68,4 @@ Clear existing status and snooze durations
Snooze notifications without updating your status Snooze notifications without updating your status
slack-status --duration 15m --snooze 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.

22
auth.go
View File

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
@ -20,15 +19,6 @@ var (
defaultClientSecret = "" defaultClientSecret = ""
) )
func getEnvOrDefault(name, defaultValue string) string {
val, ok := os.LookupEnv(name)
if ok {
return val
}
return defaultValue
}
type slackApp struct { type slackApp struct {
clientID, clientSecret, redirectURI string clientID, clientSecret, redirectURI string
scopes, userScopes []string scopes, userScopes []string
@ -94,14 +84,6 @@ func (app slackApp) listenForCode() (string, error) {
return code, nil 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 { func generateSelfSignedCertificates(certPath, keyPath string) error {
command := exec.Command( command := exec.Command(
"openssl", "openssl",
@ -125,8 +107,8 @@ func generateSelfSignedCertificates(certPath, keyPath string) error {
func authenticate() (string, error) { func authenticate() (string, error) {
app := slackApp{ app := slackApp{
userScopes: []string{"dnd:write", "users.profile:write"}, userScopes: []string{"dnd:write", "users.profile:write", "team:read"},
scopes: []string{"dnd:write", "users.profile:write"}, scopes: []string{"dnd:write", "users.profile:write", "team:read"},
clientID: getEnvOrDefault("CLIENT_ID", defaultClientID), clientID: getEnvOrDefault("CLIENT_ID", defaultClientID),
clientSecret: getEnvOrDefault("CLIENT_SECRET", defaultClientSecret), clientSecret: getEnvOrDefault("CLIENT_SECRET", defaultClientSecret),
redirectURI: "https://localhost:8888/auth", redirectURI: "https://localhost:8888/auth",

136
config.go Normal file
View File

@ -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)
}

146
main.go
View File

@ -3,10 +3,7 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"io/ioutil"
"log" "log"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -15,10 +12,14 @@ import (
// statusInfo contains all args passed from the command line // statusInfo contains all args passed from the command line
type statusInfo struct { type statusInfo struct {
// status contents
emoji, statusText string emoji, statusText string
duration time.Duration duration time.Duration
snooze bool snooze bool
accessToken string
// domain and login management
login, makeDefault bool
domain string
} }
// getExipirationTime returns epoch time that status should expire from the duration. // 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() 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. // 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 // 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". // 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. // readFlags will read all flags off the command line.
func readFlags() statusInfo { 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") snooze := flag.Bool("snooze", false, "snooze notifications")
duration := flag.Duration("duration", 0, "duration to set status for") duration := flag.Duration("duration", 0, "duration to set status for")
emoji := flag.String("emoji", "", "emoji to use as status") emoji := flag.String("emoji", "", "emoji to use as status")
accessToken := flag.String("access-token", "", "slack access token")
flag.Parse() flag.Parse()
@ -134,48 +103,89 @@ func readFlags() statusInfo {
statusText := strings.Join(args, " ") statusText := strings.Join(args, " ")
return statusInfo{ return statusInfo{
duration: *duration, duration: *duration,
snooze: *snooze, snooze: *snooze,
emoji: *emoji, emoji: *emoji,
accessToken: *accessToken, statusText: statusText,
statusText: statusText,
login: *login,
domain: *domain,
makeDefault: *makeDefault,
} }
} }
func getAccessToken(accessToken string) (string, error) { // loginAndSave will return a client after a new login flow and save the results
// If provided, save and return func loginAndSave(domain string) (*slack.Client, error) {
if accessToken != "" { accessToken, err := authenticate()
return accessToken, writeAccessToken(accessToken) if err != nil {
return nil, fmt.Errorf("failed to authenticate new login: %w", err)
} }
// Try to get from stored file client := slack.New(accessToken)
accessToken, err := readAccessToken()
if accessToken != "" && err == nil { if domain == "" {
// Successfully read from file info, err := client.GetTeamInfo()
return accessToken, nil 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 err = saveLogin(domain, accessToken)
accessToken, err = authenticate() if err != nil {
return client, fmt.Errorf("failed saving new login info: %w", err)
if err == nil {
// Successful authentication, save the token
err = writeAccessToken(accessToken)
} }
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() { func main() {
args := readFlags() args := readFlags()
accessToken, err := getAccessToken(args.accessToken) var client *slack.Client
if err != nil { var err error
fmt.Println("error getting access token")
log.Fatal(err) // 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()) err = client.SetUserCustomStatus(args.statusText, args.emoji, args.getExpirationTime())
if err != nil { if err != nil {
fmt.Println("error setting status") fmt.Println("error setting status")

20
util.go Normal file
View File

@ -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
}