├── .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 |
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 |
--------------------------------------------------------------------------------