Add ability to support multiple workspaces
Adds a new perm to allow fetching the workspace domain
This commit is contained in:
parent
461ddafa58
commit
b5782a3d30
5
Makefile
5
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
|
||||
|
31
README.md
31
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 <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
|
||||
|
||||
@ -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
|
||||
|
22
auth.go
22
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",
|
||||
|
136
config.go
Normal file
136
config.go
Normal 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)
|
||||
}
|
138
main.go
138
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()
|
||||
|
||||
@ -137,45 +106,86 @@ func readFlags() statusInfo {
|
||||
duration: *duration,
|
||||
snooze: *snooze,
|
||||
emoji: *emoji,
|
||||
accessToken: *accessToken,
|
||||
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)
|
||||
}
|
||||
|
||||
// Begin auth process to fetch a new token
|
||||
accessToken, err = authenticate()
|
||||
|
||||
if err == nil {
|
||||
// Successful authentication, save the token
|
||||
err = writeAccessToken(accessToken)
|
||||
domain = info.Domain
|
||||
}
|
||||
|
||||
return accessToken, err
|
||||
err = saveLogin(domain, accessToken)
|
||||
if err != nil {
|
||||
return client, fmt.Errorf("failed saving new login info: %w", 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")
|
||||
|
20
util.go
Normal file
20
util.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user