├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── api.go ├── config.go ├── contrib └── vim-slackcat │ ├── README.md │ └── plugin │ └── vim-slackcat.vim ├── demo.gif ├── docs └── configuration-guide.md ├── go.mod ├── go.sum ├── main.go ├── output.go ├── queue.go ├── slackcat.go └── slackcat.rb /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Bradley Cicenas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=slackcat 2 | VERSION=$(shell cat VERSION) 3 | BUILD=$(shell git rev-parse --short HEAD) 4 | LDFLAGS="-s -X main.version=$(VERSION) -X main.build=$(BUILD)" 5 | 6 | clean: 7 | rm -rf _build/ _release/ _aur/ 8 | 9 | deps: 10 | go mod download 11 | 12 | build: deps 13 | go build -tags osusergo,netgo -ldflags $(LDFLAGS) -o slackcat 14 | 15 | build-all: deps 16 | mkdir -p _build 17 | GOOS=darwin GOARCH=amd64 go build -tags osusergo,netgo -ldflags $(LDFLAGS) -o _build/slackcat-$(VERSION)-darwin-amd64 18 | GOOS=linux GOARCH=amd64 go build -tags osusergo,netgo -ldflags $(LDFLAGS) -o _build/slackcat-$(VERSION)-linux-amd64 19 | GOOS=linux GOARCH=arm go build -tags osusergo,netgo -ldflags $(LDFLAGS) -o _build/slackcat-$(VERSION)-linux-arm 20 | GOOS=freebsd GOARCH=amd64 go build -tags osusergo,netgo -ldflags $(LDFLAGS) -o _build/slackcat-$(VERSION)-freebsd-amd64 21 | cd _build; sha256sum * > sha256sums.txt 22 | 23 | release: 24 | mkdir _release 25 | cp _build/* _release/ 26 | cd _release; sha256sum --quiet --check sha256sums.txt && \ 27 | gh release create $(VERSION) -d -t v$(VERSION) * 28 | 29 | aur: 30 | git clone ssh://aur@aur.archlinux.org/slackcat.git _aur 31 | cd _aur && \ 32 | sed -i "/^pkgver=/c\pkgver=$(VERSION)" PKGBUILD && \ 33 | makepkg --printsrcinfo > .SRCINFO 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slackcat 2 | Slackcat is a simple commandline utility to post snippets to Slack. 3 | 4 | 5 | slackcat 6 | 7 | 8 | ## Installing 9 | Download the [latest release](https://github.com/bcicen/slackcat/releases) for your platform: 10 | 11 | ```bash 12 | curl -Lo slackcat https://github.com/bcicen/slackcat/releases/download/1.7.2/slackcat-1.7.2-$(uname -s)-amd64 13 | sudo mv slackcat /usr/local/bin/ 14 | sudo chmod +x /usr/local/bin/slackcat 15 | ``` 16 | 17 | `slackcat` is also available via homebrew: 18 | ```brew 19 | brew install slackcat 20 | ``` 21 | 22 | ## Building 23 | To optionally build `slackcat` from source, ensure you have [dep](https://github.com/golang/dep) installed and run: 24 | ``` 25 | go get github.com/bcicen/slackcat && \ 26 | cd $GOPATH/src/github.com/bcicen/slackcat && \ 27 | make build 28 | ``` 29 | 30 | You must use GNU make for the build to work correctly. If your platform does not install GNU make as `make` (i.e. OpenBSD) then you will need to install gmake and run: 31 | ``` 32 | go get github.com/bcicen/slackcat && \ 33 | cd $GOPATH/src/github.com/bcicen/slackcat && \ 34 | gmake build 35 | ``` 36 | 37 | ## Configuration 38 | 39 | Generate an initial config, or add a new team token with: 40 | ```bash 41 | slackcat --configure 42 | ``` 43 | You'll be prompted for a team nickname and a new browser window will be opened for you to confirm the request via Slack. Provide the returned token to slackcat when prompted, and you're ready to go! 44 | 45 | For configuring multiple teams and default channels, see [Configuration Guide](https://github.com/bcicen/slackcat/blob/master/docs/configuration-guide.md). 46 | 47 | ## Usage 48 | Pipe command output as a text snippet: 49 | ```bash 50 | $ echo -e "hi\nthere" | slackcat --channel general --filename hello 51 | *slackcat* file hello uploaded to general 52 | ``` 53 | 54 | Post an existing file: 55 | ```bash 56 | $ slackcat --channel general /home/user/bot.png 57 | *slackcat* file bot.png uploaded to general 58 | ``` 59 | 60 | Stream input continuously: 61 | ```bash 62 | $ tail -F -n0 /path/to/log | slackcat --channel general --stream 63 | *slackcat* posted 5 message lines to general 64 | *slackcat* posted 2 message lines to general 65 | ... 66 | ``` 67 | 68 | ## Options 69 | 70 | Option | Description 71 | --- | --- 72 | --tee, -t | Print stdin to screen before posting 73 | --stream, -s | Stream messages to Slack continuously instead of uploading a single snippet 74 | --noop | Skip posting file to Slack. Useful for testing 75 | --configure | Configure Slackcat via oauth 76 | --iconemoji, -i | Specify emoji icon for message (e.g. ":+1:") 77 | --channel, -c | Slack channel, group, or user to post to 78 | --filename, -n | Filename for upload. Defaults to given filename or current timestamp if reading from stdin 79 | --filetype | Specify filetype for syntax highlighting. Defaults to autodetect 80 | --comment | Initial comment for snippet 81 | --username | Stream messages as given bot user. Defaults to auth user 82 | --thread | Stream messages to thread after initial comment message 83 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.7.3 2 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/slack-go/slack" 8 | ) 9 | 10 | var ( 11 | api *slack.Client 12 | msgOpts = &slack.PostMessageParameters{AsUser: true} 13 | ) 14 | 15 | // InitAPI for Slack 16 | func InitAPI(token string) { 17 | api = slack.New(token) 18 | res, err := api.AuthTest() 19 | failOnError(err, "Slack API Error") 20 | output(fmt.Sprintf("connected to %s as %s", res.Team, res.User)) 21 | } 22 | 23 | // Return list of all channels by name 24 | func listChannels() (names []string) { 25 | list, err := getConversations("public_channel", "private_channel") 26 | failOnError(err) 27 | for _, c := range list { 28 | names = append(names, c.Name) 29 | } 30 | return names 31 | } 32 | 33 | // Return list of all groups by name 34 | func listGroups() (names []string) { 35 | list, err := getConversations("mpim") 36 | failOnError(err) 37 | for _, c := range list { 38 | names = append(names, c.Name) 39 | } 40 | return names 41 | } 42 | 43 | // Return list of all ims by name 44 | func listIms() (names []string) { 45 | users, err := api.GetUsers() 46 | failOnError(err) 47 | 48 | list, err := getConversations("im") 49 | failOnError(err) 50 | for _, c := range list { 51 | for _, u := range users { 52 | if u.ID == c.User { 53 | names = append(names, u.Name) 54 | continue 55 | } 56 | } 57 | } 58 | return names 59 | } 60 | 61 | // Lookup Slack id for channel, group, or im by name 62 | func lookupSlackID(name string) string { 63 | list, err := getConversations("public_channel", "private_channel", "mpim") 64 | if err == nil { 65 | for _, c := range list { 66 | if c.Name == name { 67 | return c.ID 68 | } 69 | } 70 | } 71 | users, err := api.GetUsers() 72 | if err == nil { 73 | list, err := getConversations("im") 74 | if err == nil { 75 | for _, c := range list { 76 | for _, u := range users { 77 | if u.Name == name && u.ID == c.User { 78 | return c.ID 79 | } 80 | } 81 | } 82 | } 83 | } 84 | exitErr(fmt.Errorf("No such channel, group, or im")) 85 | return "" 86 | } 87 | 88 | func getConversations(types ...string) (list []slack.Channel, err error) { 89 | cursor := "" 90 | for { 91 | param := &slack.GetConversationsParameters{ 92 | Cursor: cursor, 93 | ExcludeArchived: "true", 94 | Types: types, 95 | Limit: 1000, 96 | } 97 | channels, cur, err := api.GetConversations(param) 98 | if err != nil { 99 | if rateLimitedError, ok := err.(*slack.RateLimitedError); ok { 100 | output(fmt.Sprintf("%v", rateLimitedError)) 101 | time.Sleep(rateLimitedError.RetryAfter) 102 | continue 103 | } 104 | return list, err 105 | } 106 | list = append(list, channels...) 107 | if cur == "" { 108 | break 109 | } 110 | cursor = cur 111 | } 112 | return list, nil 113 | } 114 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/BurntSushi/toml" 11 | "github.com/skratchdot/open-golang/open" 12 | ) 13 | 14 | const AuthURL = "http://slackcat.chat/configure" 15 | 16 | // Slack team and channel read from file 17 | type Config struct { 18 | Teams map[string]string `toml:"teams"` 19 | DefaultTeam string `toml:"default_team"` 20 | DefaultChannel string `toml:"default_channel"` 21 | } 22 | 23 | // NewConfig returns new default config 24 | func NewConfig() *Config { 25 | return &Config{ 26 | Teams: make(map[string]string), 27 | } 28 | } 29 | 30 | // ReadConfig returns config read from file 31 | func ReadConfig(path string) *Config { 32 | config := NewConfig() 33 | lines, err := readLines(path) 34 | failOnError(err, "unable to read config") 35 | 36 | // simple config file 37 | if len(lines) == 1 { 38 | config.Teams["default"] = lines[0] 39 | config.DefaultTeam = "default" 40 | return config 41 | } 42 | 43 | // advanced config file 44 | body := strings.Join(lines, "\n") 45 | _, err = toml.Decode(body, &config) 46 | failOnError(err, "failed to parse config") 47 | 48 | return config 49 | } 50 | 51 | func (c *Config) Write(path string) { 52 | cfgdir := basedir(path) 53 | // create config dir if not exist 54 | if _, err := os.Stat(cfgdir); err != nil { 55 | err = os.MkdirAll(cfgdir, 0755) 56 | if err != nil { 57 | exitErr(fmt.Errorf("failed to initialize config dir [%s]: %s", cfgdir, err)) 58 | } 59 | } 60 | 61 | file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644) 62 | if err != nil { 63 | exitErr(fmt.Errorf("failed to open config for writing: %s", err)) 64 | } 65 | 66 | writer := toml.NewEncoder(file) 67 | err = writer.Encode(c) 68 | if err != nil { 69 | exitErr(fmt.Errorf("failed to write config: %s", err)) 70 | } 71 | } 72 | 73 | func (c *Config) parseChannelOpt(channel string) (string, string, error) { 74 | // use default channel if none provided 75 | if channel == "" { 76 | if c.DefaultChannel == "" { 77 | return "", "", fmt.Errorf("no channel provided") 78 | } 79 | return c.DefaultTeam, c.DefaultChannel, nil 80 | } 81 | // if channel is prefixed with a team 82 | if strings.Contains(channel, ":") { 83 | s := strings.Split(channel, ":") 84 | return s[0], s[1], nil 85 | } 86 | // use default team with provided channel 87 | return c.DefaultTeam, channel, nil 88 | } 89 | 90 | // determine config path from environment 91 | func getConfigPath() (path string, exists bool) { 92 | userHome, ok := os.LookupEnv("HOME") 93 | if !ok { 94 | exitErr(fmt.Errorf("$HOME not set")) 95 | } 96 | 97 | path = fmt.Sprintf("%s/.slackcat", userHome) // default path 98 | 99 | if xdgSupport() { 100 | xdgHome, ok := os.LookupEnv("XDG_CONFIG_HOME") 101 | if !ok { 102 | xdgHome = fmt.Sprintf("%s/.config", userHome) 103 | } 104 | path = fmt.Sprintf("%s/slackcat/config", xdgHome) 105 | } 106 | 107 | if _, err := os.Stat(path); err == nil { 108 | exists = true 109 | } 110 | 111 | return path, exists 112 | } 113 | 114 | func basedir(path string) string { 115 | parts := strings.Split(path, "/") 116 | return strings.Join((parts[0 : len(parts)-1]), "/") 117 | } 118 | 119 | // Test for environemnt supporting XDG spec 120 | func xdgSupport() bool { 121 | re := regexp.MustCompile("^XDG_*") 122 | for _, e := range os.Environ() { 123 | if re.FindAllString(e, 1) != nil { 124 | return true 125 | } 126 | } 127 | return false 128 | } 129 | 130 | func configureOA() { 131 | var nick, token string 132 | var config *Config 133 | 134 | cfgPath, cfgExists := getConfigPath() 135 | if !cfgExists { 136 | config = NewConfig() 137 | } else { 138 | config = ReadConfig(cfgPath) 139 | } 140 | 141 | fmt.Printf("nickname for team: ") 142 | fmt.Scanf("%s", &nick) 143 | if nick == "" { 144 | exitErr(fmt.Errorf("no name provided")) 145 | } 146 | 147 | output("creating token request for slackcat") 148 | open.Run(AuthURL) 149 | output("Use the below URL to authorize slackcat if browser fails to launch") 150 | output(AuthURL) 151 | 152 | fmt.Printf("token issued: ") 153 | fmt.Scanf("%s", &token) 154 | if token == "" { 155 | exitErr(fmt.Errorf("no token provided")) 156 | } 157 | 158 | // creating a new config file 159 | if !cfgExists { 160 | config.DefaultTeam = nick 161 | } 162 | config.Teams[nick] = token 163 | config.Write(cfgPath) 164 | 165 | output(fmt.Sprintf("added team to config file at %s", cfgPath)) 166 | } 167 | 168 | func readLines(path string) (lines []string, err error) { 169 | file, err := os.Open(path) 170 | if err != nil { 171 | return lines, err 172 | } 173 | defer file.Close() 174 | 175 | scanner := bufio.NewScanner(file) 176 | for scanner.Scan() { 177 | if scanner.Text() != "" { 178 | lines = append(lines, scanner.Text()) 179 | } 180 | } 181 | return lines, nil 182 | } 183 | -------------------------------------------------------------------------------- /contrib/vim-slackcat/README.md: -------------------------------------------------------------------------------- 1 | # vim-slackcat.vim 2 | Ridiculously simple plugin to send a visual selection to an Slack channel 3 | 4 | ## Installing 5 | 6 | Install via your preferred plugin manager; e.g. Vundle: 7 | ``` 8 | Plugin 'bcicen/slackcat', {'rtp': 'contrib/vim-slackcat' } 9 | ``` 10 | 11 | Or manually: 12 | ``` 13 | curl -Lo ~/.vim/plugin/slackcat.vim https://raw.githubusercontent.com/bcicen/slackcat/master/contrib/vim-slackcat/plugin/vim-slackcat.vim 14 | ``` 15 | 16 | ## Notes 17 | * By default, the command is mapped to `s` 18 | * It accepts `g:slackcat_default_channel` as a config variable in your .vimrc 19 | 20 | slackcat (http://slackcat.chat/) must be configured beforehand. 21 | 22 | ## License 23 | Copyright © 2016 Paco Esteban 24 | 25 | Permission is hereby granted, free of charge, to any person obtaining 26 | a copy of this software and associated documentation files (the 'Software'), 27 | to deal in the Software without restriction, including without limitation 28 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 29 | and/or sell copies of the Software, and to permit persons to whom the 30 | Software is furnished to do so, subject to the following conditions: 31 | 32 | The above copyright notice and this permission notice shall be included 33 | in all copies or substantial portions of the Software. 34 | 35 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 36 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 37 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 38 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 39 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 40 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 41 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 42 | -------------------------------------------------------------------------------- /contrib/vim-slackcat/plugin/vim-slackcat.vim: -------------------------------------------------------------------------------- 1 | " vim-slackcat.vim 2 | " Ridiculously simple plugin to send a visual selection to an Slack channel 3 | " 4 | " Copyright © 2016 Paco Esteban 5 | 6 | " Permission is hereby granted, free of charge, to any person obtaining 7 | " a copy of this software and associated documentation files (the 'Software'), 8 | " to deal in the Software without restriction, including without limitation 9 | " the rights to use, copy, modify, merge, publish, distribute, sublicense, 10 | " and/or sell copies of the Software, and to permit persons to whom the 11 | " Software is furnished to do so, subject to the following conditions: 12 | 13 | " The above copyright notice and this permission notice shall be included 14 | " in all copies or substantial portions of the Software. 15 | 16 | " THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | " EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | " OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | " IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 20 | " DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | " TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 22 | " OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | " it accepts g:slackcat_default_channel 25 | " slackcat (http://slackcat.chat/) must be configured beforehand. 26 | 27 | if !exists("g:slackcat_default_channel") 28 | let g:slackcat_default_channel = "" 29 | endif 30 | 31 | " send selection to slack 32 | vnoremap s :call SendToSlack() 33 | 34 | function! SendToSlack() 35 | call inputsave() 36 | let s_channel = input("Slack Channel? ", g:slackcat_default_channel) 37 | let s_lang = input("lang? ", &filetype) 38 | call inputrestore() 39 | echo "\rSending to Slack ..." 40 | let s_selection = s:escapeTildes(s:getVisualSelection()) 41 | if empty(s_lang) 42 | let s_lang = 'txt' 43 | endif 44 | let return = system("echo '". s_selection ."' |slackcat -c " . s_channel . " --filetype " . s_lang) 45 | echo "\rSent !" 46 | endfunction 47 | 48 | function! s:escapeTildes(text) 49 | return substitute(a:text, "'", "'\"'\"'", 'g') 50 | endfunction 51 | 52 | function! s:getVisualSelection() 53 | " Why is this not a built-in Vim script function?! 54 | let [lnum1, col1] = getpos("'<")[1:2] 55 | let [lnum2, col2] = getpos("'>")[1:2] 56 | let lines = getline(lnum1, lnum2) 57 | let lines[-1] = lines[-1][: col2 - (&selection == 'inclusive' ? 1 : 2)] 58 | let lines[0] = lines[0][col1 - 1:] 59 | return join(lines, "\n") 60 | endfunction 61 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcicen/slackcat/5c4e4410002c29da54f9819970c44accd0db0b72/demo.gif -------------------------------------------------------------------------------- /docs/configuration-guide.md: -------------------------------------------------------------------------------- 1 | # Configuration Guide 2 | Slackcat may be configured via a simple or advanced configuration. 3 | 4 | ## Default Path 5 | If your environment specifies an [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/latest/index.html), `slackcat` will use the configuration file at `~/.config/slackcat/config`; otherwise, will fallback to `~/.slackcat` 6 | 7 | ## Simple Configuration 8 | 9 | Generate a new Slack token with: 10 | ```bash 11 | slackcat --configure 12 | ``` 13 | A new browser window will be opened for you to confirm the request via Slack, and you'll be returned a token. 14 | 15 | Create a Slackcat config file and you're ready to go! 16 | ```bash 17 | echo '' > ~/.slackcat 18 | ``` 19 | 20 | ## Advanced Configuration 21 | 22 | Advanced configuration allows for multiple Slack teams, a default team, and default channel in [TOML](https://github.com/toml-lang/toml) format. 23 | 24 | #### Example ~/.config/slackcat Config 25 | ```bash 26 | default_team = "team1" 27 | default_channel = "general" 28 | 29 | [teams] 30 | team1 = "" 31 | team2 = "" 32 | ``` 33 | By default, all messages will be sent to the team1 general channel. 34 | 35 | #### Example Usage 36 | 37 | Post a file to team1 #general channel: 38 | ```bash 39 | slackcat /path/to/file.txt 40 | ``` 41 | 42 | Post a file to team1 #testing channel: 43 | ```bash 44 | slackcat -c testing /path/to/file.txt 45 | ``` 46 | 47 | Post a file to team2 #testing channel: 48 | ```bash 49 | slackcat -c team2:testing /path/to/file.txt 50 | ``` 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bcicen/slackcat 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.0 7 | github.com/fatih/color v1.5.0 8 | github.com/mattn/go-colorable v0.1.8 // indirect 9 | github.com/mattn/go-isatty v0.0.12 // indirect 10 | github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c 11 | github.com/slack-go/slack v0.8.1 12 | github.com/urfave/cli v1.20.0 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.0 h1:e1/Ivsx3Z0FVTV0NSOv/aVgbUWyQuzj7DDnFblkRvsY= 2 | github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/fatih/color v1.5.0 h1:vBh+kQp8lg9XPr56u1CPrWjFXtdphMoGWVHr9/1c+A0= 6 | github.com/fatih/color v1.5.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 7 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 8 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 9 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 10 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 11 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 12 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 13 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= 14 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 15 | github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= 16 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 17 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 18 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 19 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 20 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c h1:fyKiXKO1/I/B6Y2U8T7WdQGWzwehOuGIrljPtt7YTTI= 24 | github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 25 | github.com/slack-go/slack v0.8.1 h1:NqGXuzni8Is3EJWmsuMuBiCCPbWOlBgTKPvdlwS3Huk= 26 | github.com/slack-go/slack v0.8.1/go.mod h1:FGqNzJBmxIsZURAxh2a8D21AnOVvvXZvGligs4npPUM= 27 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 28 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 29 | github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= 30 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 31 | golang.org/x/sys v0.0.0-20171123182949-a13efeb2fd21 h1:i8c2841iGqFgiUiEQFmDkl5qGkfTS0axK9pPblMoTEU= 32 | golang.org/x/sys v0.0.0-20171123182949-a13efeb2fd21/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 33 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= 35 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/urfave/cli" 11 | ) 12 | 13 | var ( 14 | noop = false 15 | build = "" 16 | version = "dev-build" 17 | thread = false 18 | ) 19 | 20 | type StdinScanner struct { 21 | *bufio.Scanner 22 | tee bool 23 | } 24 | 25 | func NewStdinScanner(tee bool) *StdinScanner { 26 | return &StdinScanner{bufio.NewScanner(os.Stdin), tee} 27 | } 28 | 29 | func (s *StdinScanner) StreamBytes() chan []byte { 30 | ch := make(chan []byte) 31 | s.Split(bufio.ScanBytes) 32 | go func() { 33 | for s.Scan() { 34 | b := s.Bytes() 35 | ch <- b 36 | if s.tee { 37 | fmt.Printf("%s", b) 38 | } 39 | } 40 | failOnError(s.Err(), "error reading input") 41 | close(ch) 42 | }() 43 | return ch 44 | } 45 | 46 | func (s *StdinScanner) StreamLines() chan string { 47 | ch := make(chan string) 48 | s.Split(bufio.ScanLines) 49 | go func() { 50 | for s.Scan() { 51 | ch <- s.Text() 52 | if s.tee { 53 | fmt.Println(s.Text()) 54 | } 55 | } 56 | failOnError(s.Err(), "error reading input") 57 | close(ch) 58 | }() 59 | return ch 60 | } 61 | 62 | func writeTemp(byteCh chan []byte) string { 63 | tmp, err := ioutil.TempFile(os.TempDir(), "slackcat-") 64 | failOnError(err, "unable to create tmpfile") 65 | 66 | w := bufio.NewWriter(tmp) 67 | for b := range byteCh { 68 | _, err := w.Write(b) 69 | failOnError(err, "error writing to tmpfile") 70 | } 71 | w.Flush() 72 | 73 | return tmp.Name() 74 | } 75 | 76 | func handleUsageError(c *cli.Context, err error, _ bool) error { 77 | fmt.Fprintf(c.App.Writer, "%s %s\n\n", "Incorrect Usage.", err.Error()) 78 | cli.ShowAppHelp(c) 79 | return cli.NewExitError("", 1) 80 | } 81 | 82 | func printFullVersion(c *cli.Context) { 83 | fmt.Fprintf(c.App.Writer, "%v version %v, build %v\n", c.App.Name, c.App.Version, build) 84 | } 85 | 86 | func main() { 87 | cli.VersionPrinter = printFullVersion 88 | 89 | app := cli.NewApp() 90 | app.Name = "slackcat" 91 | app.Usage = "redirect a file to slack" 92 | app.Version = version 93 | app.OnUsageError = handleUsageError 94 | app.Flags = []cli.Flag{ 95 | cli.StringFlag{ 96 | Name: "channel, c", 97 | Usage: "Slack channel or group to post to", 98 | }, 99 | cli.StringFlag{ 100 | Name: "comment", 101 | Usage: "Initial comment for snippet", 102 | }, 103 | cli.BoolFlag{ 104 | Name: "configure", 105 | Usage: "Configure Slackcat via oauth", 106 | }, 107 | cli.StringFlag{ 108 | Name: "filename, n", 109 | Usage: "Filename for upload. Defaults to current timestamp", 110 | }, 111 | cli.StringFlag{ 112 | Name: "filetype", 113 | Usage: "Specify filetype for syntax highlighting", 114 | }, 115 | cli.BoolFlag{ 116 | Name: "list", 117 | Usage: "List team channel names", 118 | }, 119 | cli.BoolFlag{ 120 | Name: "noop", 121 | Usage: "Skip posting file to Slack. Useful for testing", 122 | }, 123 | cli.BoolFlag{ 124 | Name: "stream, s", 125 | Usage: "Stream messages to Slack continuously instead of uploading a single snippet", 126 | }, 127 | cli.BoolFlag{ 128 | Name: "tee, t", 129 | Usage: "Print stdin to screen before posting", 130 | }, 131 | cli.StringFlag{ 132 | Name: "token", 133 | Usage: "Optional Slack token to use, ignoring config file", 134 | }, 135 | cli.StringFlag{ 136 | Name: "username, u", 137 | Usage: "Stream messages as given bot user. Defaults to auth user", 138 | }, 139 | cli.StringFlag{ 140 | Name: "iconemoji, i", 141 | Usage: "Stream messages as given bot icon emoji. Defaults to auth user's icon", 142 | }, 143 | cli.BoolFlag{ 144 | Name: "thread", 145 | Usage: "Send subsequent messages as threaded reply to orignial message", 146 | }, 147 | } 148 | 149 | app.Action = func(c *cli.Context) { 150 | var config *Config 151 | 152 | if c.Bool("configure") { 153 | configureOA() 154 | os.Exit(0) 155 | } 156 | 157 | if c.String("token") != "" { 158 | config = &Config{ 159 | Teams: map[string]string{ 160 | "default": c.String("token"), 161 | }, 162 | DefaultTeam: "default", 163 | } 164 | } else { 165 | configPath, exists := getConfigPath() 166 | if !exists { 167 | exitErr(fmt.Errorf("missing config file at %s\nuse --configure to create", configPath)) 168 | } 169 | config = ReadConfig(configPath) 170 | } 171 | 172 | if c.Bool("list") { 173 | for teamName, token := range config.Teams { 174 | InitAPI(token) 175 | for _, n := range listChannels() { 176 | fmt.Printf("[%s] [channel] %s\n", teamName, n) 177 | } 178 | for _, n := range listGroups() { 179 | fmt.Printf("[%s] [group] %s\n", teamName, n) 180 | } 181 | for _, n := range listIms() { 182 | fmt.Printf("[%s] [im] %s\n", teamName, n) 183 | } 184 | } 185 | os.Exit(0) 186 | } 187 | 188 | team, channel, err := config.parseChannelOpt(c.String("channel")) 189 | failOnError(err) 190 | 191 | noop = c.Bool("noop") 192 | thread = c.Bool("thread") 193 | username := c.String("username") 194 | iconEmoji := c.String("iconemoji") 195 | fileName := c.String("filename") 196 | fileType := c.String("filetype") 197 | fileComment := c.String("comment") 198 | 199 | token := config.Teams[team] 200 | if token == "" { 201 | exitErr(fmt.Errorf("no such team: %s", team)) 202 | } 203 | 204 | InitAPI(token) 205 | slackcat := newSlackcat(username, iconEmoji, channel) 206 | 207 | if len(c.Args()) > 0 { 208 | if c.Bool("stream") { 209 | output("filepath provided, ignoring stream option") 210 | } 211 | filePath := c.Args()[0] 212 | if fileName == "" { 213 | fileName = filepath.Base(filePath) 214 | } 215 | slackcat.postFile(filePath, fileName, fileType, fileComment) 216 | os.Exit(0) 217 | } 218 | 219 | scanner := NewStdinScanner(c.Bool("tee")) 220 | 221 | if c.Bool("stream") { 222 | // If threaded then send comment first to start thread 223 | if thread { 224 | var s []string 225 | if fileComment != "" { 226 | s = append(s, fileComment) 227 | } else { 228 | s = append(s, "Slackcat Stream Output:") 229 | } 230 | slackcat.postMsg(s) 231 | } 232 | slackcat.stream(scanner.StreamLines()) 233 | } else { 234 | filePath := writeTemp(scanner.StreamBytes()) 235 | defer os.Remove(filePath) 236 | slackcat.postFile(filePath, fileName, fileType, fileComment) 237 | os.Exit(0) 238 | } 239 | } 240 | 241 | app.Run(os.Args) 242 | 243 | } 244 | -------------------------------------------------------------------------------- /output.go: -------------------------------------------------------------------------------- 1 | // output/err convenience methods 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | "github.com/fatih/color" 9 | ) 10 | 11 | var ( 12 | bold = color.New(color.Bold).SprintFunc() 13 | red = color.New(color.FgRed).SprintFunc() 14 | cyan = color.New(color.FgCyan).SprintFunc() 15 | ) 16 | 17 | func output(s string) { 18 | fmt.Printf("%s %s\n", cyan("slackcat"), s) 19 | } 20 | 21 | func failOnError(err error, msg ...string) { 22 | if err != nil { 23 | if msg != nil { 24 | err = fmt.Errorf("%s: %s", msg[0], err) 25 | } 26 | exitErr(err) 27 | } 28 | } 29 | 30 | func appendErr(msg string, err error) error { 31 | return fmt.Errorf("%s: %s", msg, err) 32 | } 33 | 34 | func exitErr(err error) { 35 | output(red(err.Error())) 36 | os.Exit(1) 37 | } 38 | -------------------------------------------------------------------------------- /queue.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | //Streaming message queue 8 | type StreamQ struct { 9 | lines []string 10 | unAckd []string // lines being processed 11 | lock sync.RWMutex 12 | } 13 | 14 | func newStreamQ() *StreamQ { 15 | return &StreamQ{ 16 | lines: []string{}, 17 | unAckd: []string{}, 18 | lock: sync.RWMutex{}, 19 | } 20 | } 21 | 22 | func (q *StreamQ) Len() int { return len(q.lines) + len(q.unAckd) } 23 | func (q *StreamQ) IsEmpty() bool { return q.Len() < 1 } 24 | 25 | func (q *StreamQ) Add(line string) { 26 | q.lock.Lock() 27 | q.lines = append(q.lines, line) 28 | q.lock.Unlock() 29 | } 30 | 31 | // Flush returns all lines in queue 32 | func (q *StreamQ) Flush() []string { 33 | q.lock.Lock() 34 | defer q.lock.Unlock() 35 | for _, l := range q.lines { 36 | q.unAckd = append(q.unAckd, l) 37 | } 38 | q.lines = []string{} 39 | return q.unAckd 40 | } 41 | 42 | // acknowledge items from last Get() have been processed 43 | func (q *StreamQ) Ack() { 44 | q.lock.Lock() 45 | defer q.lock.Unlock() 46 | q.unAckd = []string{} 47 | } 48 | -------------------------------------------------------------------------------- /slackcat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/slack-go/slack" 12 | ) 13 | 14 | //Slackcat client 15 | type Slackcat struct { 16 | queue *StreamQ 17 | shutdown chan os.Signal 18 | username string 19 | iconEmoji string 20 | channelID string 21 | channelName string 22 | } 23 | 24 | func newSlackcat(username, iconEmoji, channelname string) *Slackcat { 25 | sc := &Slackcat{ 26 | queue: newStreamQ(), 27 | shutdown: make(chan os.Signal, 1), 28 | username: username, 29 | iconEmoji: iconEmoji, 30 | channelName: channelname, 31 | } 32 | 33 | sc.channelID = lookupSlackID(sc.channelName) 34 | 35 | signal.Notify(sc.shutdown, os.Interrupt) 36 | return sc 37 | } 38 | 39 | func (sc *Slackcat) trap() { 40 | sigcount := 0 41 | for sig := range sc.shutdown { 42 | if sigcount > 0 { 43 | exitErr(fmt.Errorf("aborted")) 44 | } 45 | output(fmt.Sprintf("got signal: %s", sig.String())) 46 | output("press ctrl+c again to exit immediately") 47 | sigcount++ 48 | go sc.exit() 49 | } 50 | } 51 | 52 | func (sc *Slackcat) exit() { 53 | for { 54 | if sc.queue.IsEmpty() { 55 | os.Exit(0) 56 | } else { 57 | output("flushing remaining messages to Slack...") 58 | time.Sleep(3 * time.Second) 59 | } 60 | } 61 | } 62 | 63 | func (sc *Slackcat) stream(lines chan string) { 64 | output("starting stream") 65 | 66 | go func() { 67 | for line := range lines { 68 | sc.queue.Add(line) 69 | } 70 | sc.exit() 71 | }() 72 | 73 | go sc.processStreamQ() 74 | go sc.trap() 75 | select {} 76 | } 77 | 78 | func (sc *Slackcat) processStreamQ() { 79 | if !(sc.queue.IsEmpty()) { 80 | msglines := sc.queue.Flush() 81 | if noop { 82 | output(fmt.Sprintf("skipped posting of %s message lines to %s", strconv.Itoa(len(msglines)), sc.channelName)) 83 | } else { 84 | sc.postMsg(msglines) 85 | } 86 | sc.queue.Ack() 87 | } 88 | time.Sleep(3 * time.Second) 89 | sc.processStreamQ() 90 | } 91 | 92 | var CurMsgTS string 93 | 94 | func (sc *Slackcat) postMsg(msglines []string) { 95 | msg := strings.Join(msglines, "\n") 96 | msg = strings.Replace(msg, "&", "%26amp%3B", -1) 97 | msg = strings.Replace(msg, "<", "%26lt%3B", -1) 98 | msg = strings.Replace(msg, ">", "%26gt%3B", -1) 99 | 100 | msgOpts := []slack.MsgOption{slack.MsgOptionText(msg, false)} 101 | if sc.username != "" { 102 | msgOpts = append(msgOpts, slack.MsgOptionAsUser(false)) 103 | msgOpts = append(msgOpts, slack.MsgOptionUsername(sc.username)) 104 | } else { 105 | msgOpts = append(msgOpts, slack.MsgOptionAsUser(true)) 106 | } 107 | if sc.iconEmoji != "" { 108 | msgOpts = append(msgOpts, slack.MsgOptionIconEmoji(sc.iconEmoji)) 109 | } 110 | 111 | if thread { 112 | if CurMsgTS != "" { 113 | msgOpts = append(msgOpts, slack.MsgOptionTS(CurMsgTS)) 114 | } 115 | } 116 | 117 | var err error 118 | 119 | _, CurMsgTS, err = api.PostMessage(sc.channelID, msgOpts...) 120 | 121 | failOnError(err) 122 | count := strconv.Itoa(len(msglines)) 123 | output(fmt.Sprintf("posted %s message lines to %s", count, sc.channelName)) 124 | } 125 | 126 | func (sc *Slackcat) postFile(filePath, fileName, fileType, fileComment string) { 127 | //default to timestamp for filename 128 | if fileName == "" { 129 | fileName = strconv.FormatInt(time.Now().Unix(), 10) 130 | } 131 | 132 | if noop { 133 | output(fmt.Sprintf("skipping upload of file %s to %s", fileName, sc.channelName)) 134 | return 135 | } 136 | 137 | start := time.Now() 138 | _, err := api.UploadFile(slack.FileUploadParameters{ 139 | File: filePath, 140 | Filename: fileName, 141 | Filetype: fileType, 142 | Title: fileName, 143 | InitialComment: fileComment, 144 | Channels: []string{sc.channelID}, 145 | }) 146 | failOnError(err, "error uploading file to Slack") 147 | duration := strconv.FormatFloat(time.Since(start).Seconds(), 'f', 3, 64) 148 | output(fmt.Sprintf("file %s uploaded to %s (%ss)", fileName, sc.channelName, duration)) 149 | os.Exit(0) 150 | } 151 | -------------------------------------------------------------------------------- /slackcat.rb: -------------------------------------------------------------------------------- 1 | class Slackcat < Formula 2 | desc "Simple command-line Utility to post snippets to Slack." 3 | homepage "https://github.com/bcicen/slackcat" 4 | url "https://github.com/bcicen/slackcat/archive/v0.6.tar.gz" 5 | version "0.6" 6 | sha256 "58beac16e8949a793400025ea3ce159220f21cbf3f92bf8e5530d7662d3132e9" 7 | 8 | depends_on "go" 9 | 10 | def install 11 | platform = `uname`.downcase.strip 12 | 13 | unless ENV["GOPATH"] 14 | ENV["GOPATH"] = "/tmp" 15 | end 16 | 17 | system "make" 18 | bin.install "build/slackcat-0.6-#{platform}-amd64" => "slackcat" 19 | 20 | puts "Ready to go! Generate a new Slack key with 'slackcat --configure'" 21 | end 22 | 23 | test do 24 | assert_equal(0, "/usr/local/bin/slackcat") 25 | end 26 | end 27 | --------------------------------------------------------------------------------