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
|
.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
|
||||||
|
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
|
## 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
22
auth.go
@ -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
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)
|
||||||
|
}
|
146
main.go
146
main.go
@ -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
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