diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9e6ebf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +moonsla diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a6c37ea --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +language: go + +go: + - "1.10" + +services: + - docker + +script: + - go test -v -race -coverprofile=coverage.txt -covermode=atomic + - wget https://github.com/Crazybus/lope/releases/download/0.2.0/lope-linux_amd64 -O lope && chmod +x lope + - ./lope -workDir /go/src/github.com/Crazybus/moonsla -blacklist GO golang:1.10 '/usr/local/go/bin/go get -v ./... && /usr/local/go/bin/go run build/build.go' + +after_success: + - bash <(curl -s https://codecov.io/bash) + +deploy: + provider: releases + api_key: + secure: hT+TBmMhj8yFvGfTOGPhoVuiINprm600JijpT//kgO2PBiiQ/Tcc+ShEiXGXNoXb+mngMj+w4NsPbNjFR6K1TIxsyFDrmN2RvKi01SJZMKgpbJH5+VmkMPHJX0AxMcHjYTBkz9inM6PLzFsSHrDO296gjGpM4xMvh4wuOYz/4JH9dLcsn6BdchhrA1ipKrHwsDU6DaK9YvZwjRpB3eO4s5woUuYfHgr9OkAPLCUuh6u+HCRhtxH8F9aNtNVlHm43u9lLcwJ41YVycpfRNEhHp30Nrghn4PciPoqISJVyRSSva96HSlR9+T55aCGY0ch762k9P6CeP3OQfK/2s8v5Fcq40ScUm+GLntRv49O1eJuqBkI7HImnHzrb1w329MgJTijFVxf3W7jEj4mjyttuyScMzd9b7/ERRGUzhqqQx488ICw3tU9+fnfguhEaNt3DanyijT6ZYqVA6TQTanc/z+w+uQTy7w92mRocNNYPJA17yL8BOS7EJb5vu0+ifVyXco+5McTBcJUSm+zqFzLm2Ey3VGs4eVbCY3r4XvOSr3P3MjR1iB8u+fBeVqL6TPi+p0eRPtSHjoRRPjsmoupv9fpmRIDA7hXi3GoT9HZB8fSfFtKoEgfi7lz67UPwQXADhIEnTD5SKptt897lvq1DOj4lDDETakdkBkBKIBWdh4w= + file_glob: true + file: build/moonsla* + skip_cleanup: true + on: + tags: true diff --git a/README.md b/README.md index c8c2efa..330bb66 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# moonsla -View all the slack messages! +# Moonsla + +[![Build Status](https://travis-ci.org/Crazybus/moonsla.svg?branch=master)](https://travis-ci.org/Crazybus/moonsla) + +Moonsla is a small tool to display a stream of slack messages in a single view. + +It looks something like this + +``` +10:42:37 - general - Michael Russell: Weird I never knew that slack threads were just normal messages +10:42:55 - random - Someone Else: Sweet Potato! +10:43:37 - general - John Smith: Can people please stop using threads for everything! +``` + +# Usage + +If you don't have one already you will need to generate a [slack API token](https://api.slack.com/custom-integrations/legacy-tokens) + +You need to set this to your `SLACK_TOKEN` environment variable +``` +export SLACK_TOKEN='xoxp-1231231231232323-123123123123-123123123123123-c91238917239123' +moonsla +``` + +You can also set `SLACK_CHANNELS` to a comma separated list of channels to filter for +``` +export SLACK_CHANNELS='general,random' +``` + +# Why? + +I'm not a fan of notifications because they are very intrusive. Instead I used to keep slack always open with the slackbot channel active (so I don't accidentally type shell commands into #general). Whenever I had a spare moment I would then check each slack channel to see if there was anything that needed my attention. This took a bit too long and quite often the message would be bot telling me I had just submitted a pull request + +# Future + +* Fix channel naming for slackbot and private channels +* Improve formatting of messages so that sub-teams, urls and everything else is formatted as expected +* Automatically link to the slack message so it is easy to open up the message from moonsla in slack +* Support multiple slack workspaces +* Add an optional web interface to make things look pretty and allow displaying of images diff --git a/build/build.go b/build/build.go new file mode 100644 index 0000000..83765d8 --- /dev/null +++ b/build/build.go @@ -0,0 +1,84 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" +) + +var buildDir = filepath.FromSlash("build/") + +var operatingSystems = [...]string{ + "darwin", + "linux", + "windows", +} + +var archs = [...]string{ + "386", + "amd64", +} + +func checksum(goos string, goarch string) error { + file := buildDir + "moonsla-" + goos + "_" + goarch + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + sumFile := file + ".sha256" + hash := fmt.Sprintf("%x", h.Sum(nil)) + fmt.Println(hash, file) + + hashFile, err := os.Create(sumFile) + if err != nil { + return err + } + defer hashFile.Close() + + _, err = hashFile.WriteString(hash) + if err != nil { + return err + } + return nil +} + +func build(goos string, goarch string) error { + os.Setenv("GOOS", goos) + os.Setenv("GOARCH", goarch) + + cmd := exec.Command( + "/usr/local/go/bin/go", + "build", + "-v", + "-o", + buildDir+"moonsla-"+goos+"_"+goarch, + ) + return cmd.Run() +} + +func main() { + for _, goos := range operatingSystems { + for _, goarch := range archs { + err := build(goos, goarch) + if err != nil { + log.Printf("Failed to build %s/%s with error: %v", goos, goarch, err) + os.Exit(1) + } + err = checksum(goos, goarch) + if err != nil { + log.Printf("Failed to generate checksums for %s/%s with error: %v", goos, goarch, err) + os.Exit(1) + } + } + } +} diff --git a/moonsla.go b/moonsla.go new file mode 100644 index 0000000..308d058 --- /dev/null +++ b/moonsla.go @@ -0,0 +1,143 @@ +package main + +import ( + "fmt" + "log" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/nlopes/slack" +) + +func getChannels(api *slack.Client) (channels map[string]string) { + channels = make(map[string]string) + chans, _ := api.GetChannels(true) + for _, c := range chans { + channels[c.ID] = c.Name + } + return channels +} + +func getUsers(api *slack.Client) (users map[string]string) { + users = make(map[string]string) + allUsers, _ := api.GetUsers() + for _, u := range allUsers { + users[u.ID] = u.RealName + } + return users +} + +func getTimeStamp(ts string) (timeStamp time.Time) { + i, err := strconv.ParseInt(strings.Split(ts, ".")[0], 10, 64) + if err != nil { + panic(err) + } + timeStamp = time.Unix(i, 0) + return timeStamp +} + +func formatMentions(msg string, users map[string]string) string { + re := regexp.MustCompile("<@U.*?>") + matches := re.FindAllString(msg, -1) + for _, m := range matches { + userID := m[2:(len(m) - 1)] + username, ok := users[userID] + if ok { + username = "@" + username + msg = strings.Replace(msg, m, username, -1) + } + } + return msg +} + +func filterChannel(name string, channels map[string]string, whitelist []string) (whitelisted bool, cName string) { + whitelisted = false + + cName, ok := channels[name] + if ok { + for _, w := range whitelist { + if cName == w { + whitelisted = true + } + } + } else { + whitelisted = true + cName = name + } + + if len(whitelist) == 0 { + whitelisted = true + } + + return whitelisted, cName +} + +func main() { + + slackToken, ok := os.LookupEnv("SLACK_TOKEN") + if !ok { + fmt.Println("Please set your SLACK_TOKEN") + } + api := slack.New(slackToken) + + logger := log.New(os.Stdout, "slack-bot: ", log.Lshortfile|log.LstdFlags) + slack.SetLogger(logger) + api.SetDebug(false) + + channels := getChannels(api) + fmt.Printf("Found %v channels\n", len(channels)) + + users := getUsers(api) + fmt.Printf("Found %v users\n", len(users)) + + rtm := api.NewRTM() + go rtm.ManageConnection() + + whitelist := strings.Split(os.Getenv("SLACK_CHANNELS"), ",") + fmt.Printf("Channel whitelist: %v\n", whitelist) + + for msg := range rtm.IncomingEvents { + + switch ev := msg.Data.(type) { + + case *slack.MessageEvent: + + // Skip empty messages + if ev.Text == "" { + continue + } + + whitelisted, cName := filterChannel(ev.Channel, channels, whitelist) + if !whitelisted { + continue + } + + // Map the users ID to a username if it exists + uName, ok := users[ev.User] + if !ok { + uName = ev.User + } + + t := getTimeStamp(ev.EventTimestamp) + timeStamp := fmt.Sprintf("%02d:%02d:%02d", t.Hour(), t.Minute(), t.Second()) + + msg := formatMentions(ev.Text, users) + + fmt.Printf("%v - %v - %v: %v\n", timeStamp, cName, uName, msg) + + case *slack.RTMError: + fmt.Printf("Error: %s\n", ev.Error()) + + case *slack.InvalidAuthEvent: + fmt.Printf("Invalid credentials") + return + + default: + // Ignore other events.. + // fmt.Printf("Unexpected: %v\n", msg.Data) + } + } +} diff --git a/moonsla_test.go b/moonsla_test.go new file mode 100644 index 0000000..920bcfb --- /dev/null +++ b/moonsla_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "testing" +) + +func TestGetTimeStamp(t *testing.T) { + var tests = []struct { + description string + timeStamp string + want int64 + }{ + { + "Convert timestamp to something human readable", + "1530593277.000080", + 1530593277, + }, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + ts := getTimeStamp(test.timeStamp) + got := ts.Unix() + + want := test.want + + if got != want { + t.Errorf("got '%v' want '%v'", got, want) + } + }) + } +} + +func TestFilterChannel(t *testing.T) { + var tests = []struct { + description string + id string + channels map[string]string + whitelist []string + name string + whitelisted bool + }{ + { + "Channel that is whitelisted", + "12345", + map[string]string{ + "12345": "channel-name", + }, + []string{ + "channel-name", + }, + "channel-name", + true, + }, + { + "Channel that is not whitelisted", + "12344", + map[string]string{ + "12345": "channel-name", + "12344": "spam-channel", + }, + []string{ + "channel-name", + }, + "spam-channel", + false, + }, + { + "Channel that is not in the channels list", + "123", + map[string]string{ + "12345": "channel-name", + "12344": "spam-channel", + }, + []string{ + "channel-name", + }, + "123", + true, + }, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + whitelisted, name := filterChannel(test.id, test.channels, test.whitelist) + + if name != test.name { + t.Errorf("got '%s' want '%s'", name, test.name) + } + if whitelisted != test.whitelisted { + t.Errorf("got '%v' want '%v'", whitelisted, test.whitelisted) + } + }) + } +} + +func TestFormatMentions(t *testing.T) { + var tests = []struct { + description string + message string + users map[string]string + want string + }{ + { + "Replace mentions on message", + "hello <@U1234> how are you?", + map[string]string{ + "U1234": "crazybus", + }, + "hello @crazybus how are you?", + }, + { + "Replace multiple mentions in a message", + "hello <@U1234> have you met <@U321> I think you would like them <@U1234>?", + map[string]string{ + "U1234": "crazybus", + "U321": "notcrazybus", + }, + "hello @crazybus have you met @notcrazybus I think you would like them @crazybus?", + }, + { + "Don't replace anything if there are no mentions", + "hi", + map[string]string{ + "U1234": "crazybus", + "U321": "notcrazybus", + }, + "hi", + }, + { + "Leave the id if the user can't be found", + "hi <@U999>!", + map[string]string{ + "U1234": "crazybus", + "U321": "notcrazybus", + }, + "hi <@U999>!", + }, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + got := formatMentions(test.message, test.users) + want := test.want + + if got != want { + t.Errorf("got '%s' want '%s'", got, want) + } + }) + } +}