diff --git a/Gopkg.lock b/Gopkg.lock index ee1098a..f5cfb64 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,6 +1,12 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. +[[projects]] + branch = "master" + name = "github.com/0xAX/notificator" + packages = ["."] + revision = "88d57ee9043ba88d6a62e437fa15dda1ca0d2b59" + [[projects]] name = "github.com/erroneousboat/termui" packages = ["."] @@ -50,6 +56,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "353a2a71e00ecf8dd6123a02828c450fa6d38472a98792d2d8a4cd6349900f11" + inputs-digest = "6cdfd0125aad6371a6f4e75c7fc29507cee4a6001a6c68e06c7237066a31153a" solver-name = "gps-cdcl" solver-version = 1 diff --git a/README.md b/README.md index 3803d68..3c02c46 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Slack-Term +slack-term ========== A [Slack](https://slack.com) client for your terminal. @@ -42,7 +42,12 @@ Setup "slack_token": "yourslacktokenhere", // OPTIONAL: set the width of the sidebar (between 1 and 11), default is 1 - "sidebar_width": 3, + "sidebar_width": 1, + + // OPTIONAL: turn on desktop notifications for all incoming messages, set + // the value as: "all", and for only mentions and im messages set the + // value as: "mention", default is turned off: "" + "notify": "", // OPTIONAL: define custom key mappings, defaults are: "key_map": { diff --git a/config/config.go b/config/config.go index 8e4e33e..a4d190a 100644 --- a/config/config.go +++ b/config/config.go @@ -9,9 +9,15 @@ import ( "github.com/erroneousboat/termui" ) +const ( + NotifyAll = "all" + NotifyMention = "mention" +) + // Config is the definition of a Config struct type Config struct { SlackToken string `json:"slack_token"` + Notify string `json:"notify"` SidebarWidth int `json:"sidebar_width"` MainWidth int `json:"-"` KeyMap map[string]keyMapping `json:"key_map"` @@ -43,6 +49,13 @@ func NewConfig(filepath string) (*Config, error) { cfg.MainWidth = 12 - cfg.SidebarWidth + switch cfg.Notify { + case NotifyAll, NotifyMention, "": + break + default: + return &cfg, fmt.Errorf("unsupported setting for notify: %s", cfg.Notify) + } + termui.ColorMap = map[string]termui.Attribute{ "fg": termui.StringToAttribute(cfg.Theme.View.Fg), "bg": termui.StringToAttribute(cfg.Theme.View.Bg), @@ -59,6 +72,7 @@ func getDefaultConfig() Config { return Config{ SidebarWidth: 1, MainWidth: 11, + Notify: "", KeyMap: map[string]keyMapping{ "command": { "i": "mode-insert", diff --git a/context/context.go b/context/context.go index 16f871d..3d75b64 100644 --- a/context/context.go +++ b/context/context.go @@ -4,6 +4,7 @@ import ( "net/http" _ "net/http/pprof" + "github.com/0xAX/notificator" "github.com/erroneousboat/termui" termbox "github.com/nsf/termbox-go" @@ -26,6 +27,7 @@ type AppContext struct { Config *config.Config Debug bool Mode string + Notify *notificator.Notificator } // CreateAppContext creates an application context which can be passed @@ -92,5 +94,6 @@ func CreateAppContext(flgConfig string, flgDebug bool) (*AppContext, error) { Config: config, Debug: flgDebug, Mode: CommandMode, + Notify: notificator.New(notificator.Options{AppName: "slack-term"}), }, nil } diff --git a/handlers/event.go b/handlers/event.go index 172539e..4d5473f 100644 --- a/handlers/event.go +++ b/handlers/event.go @@ -6,15 +6,18 @@ import ( "strconv" "time" + "github.com/0xAX/notificator" "github.com/erroneousboat/termui" "github.com/nlopes/slack" termbox "github.com/nsf/termbox-go" + "github.com/erroneousboat/slack-term/config" "github.com/erroneousboat/slack-term/context" "github.com/erroneousboat/slack-term/views" ) -var timer *time.Timer +var scrollTimer *time.Timer +var notifyTimer *time.Timer // actionMap binds specific action names to the function counterparts, // these action names can then be used to bind them to specific keys @@ -114,7 +117,7 @@ func messageHandler(ctx *context.AppContext) { // Add message to the selected channel if ev.Channel == ctx.Service.Channels[ctx.View.Channels.SelectedChannel].ID { - // reverse order of messages, mainly done + // Reverse order of messages, mainly done // when attachments are added to message for i := len(msg) - 1; i >= 0; i-- { ctx.View.Chat.AddMessage( @@ -134,7 +137,7 @@ func messageHandler(ctx *context.AppContext) { // window (tmux). But only create a notification when // it comes from someone else but the current user. if ev.User != ctx.Service.CurrentUserID { - actionNewMessage(ctx, ev.Channel) + actionNewMessage(ctx, ev) } case *slack.PresenceChangeEvent: actionSetPresence(ctx, ev.User, ev.Presence) @@ -252,12 +255,12 @@ func actionSearch(ctx *context.AppContext, key rune) { actionInput(ctx.View, key) go func() { - if timer != nil { - timer.Stop() + if scrollTimer != nil { + scrollTimer.Stop() } - timer = time.NewTimer(time.Second / 4) - <-timer.C + scrollTimer = time.NewTimer(time.Second / 4) + <-scrollTimer.C // Only actually search when the time expires term := ctx.View.Input.GetText() @@ -311,15 +314,15 @@ func actionGetMessages(ctx *context.AppContext) { // the list without executing the actionChangeChannel event func actionMoveCursorUpChannels(ctx *context.AppContext) { go func() { - if timer != nil { - timer.Stop() + if scrollTimer != nil { + scrollTimer.Stop() } ctx.View.Channels.MoveCursorUp() termui.Render(ctx.View.Channels) - timer = time.NewTimer(time.Second / 4) - <-timer.C + scrollTimer = time.NewTimer(time.Second / 4) + <-scrollTimer.C // Only actually change channel when the timer expires actionChangeChannel(ctx) @@ -331,15 +334,15 @@ func actionMoveCursorUpChannels(ctx *context.AppContext) { // the list without executing the actionChangeChannel event func actionMoveCursorDownChannels(ctx *context.AppContext) { go func() { - if timer != nil { - timer.Stop() + if scrollTimer != nil { + scrollTimer.Stop() } ctx.View.Channels.MoveCursorDown() termui.Render(ctx.View.Channels) - timer = time.NewTimer(time.Second / 4) - <-timer.C + scrollTimer = time.NewTimer(time.Second / 4) + <-scrollTimer.C // Only actually change channel when the timer expires actionChangeChannel(ctx) @@ -398,11 +401,24 @@ func actionChangeChannel(ctx *context.AppContext) { termui.Render(ctx.View.Chat) } -func actionNewMessage(ctx *context.AppContext, channelID string) { - ctx.Service.MarkAsUnread(channelID) +// actionNewMessage will set the new message indicator for a channel, and +// if configured will also display a desktop notification +func actionNewMessage(ctx *context.AppContext, ev *slack.MessageEvent) { + ctx.Service.MarkAsUnread(ev.Channel) ctx.View.Channels.SetChannels(ctx.Service.ChannelsToString()) termui.Render(ctx.View.Channels) + + // Terminal bell fmt.Print("\a") + + // Desktop notification + if ctx.Config.Notify == config.NotifyMention { + if ctx.Service.CheckNotifyMention(ev) { + createNotifyMessage(ctx, ev) + } + } else if ctx.Config.Notify == config.NotifyAll { + createNotifyMessage(ctx, ev) + } } func actionSetPresence(ctx *context.AppContext, channelID string, presence string) { @@ -475,3 +491,21 @@ func getKeyString(e termbox.Event) string { ek = pre + mod + k return ek } + +func createNotifyMessage(ctx *context.AppContext, ev *slack.MessageEvent) { + go func() { + if notifyTimer != nil { + notifyTimer.Stop() + } + + notifyTimer = time.NewTimer(time.Second * 2) + <-notifyTimer.C + + // Only actually notify when time expires + ctx.Notify.Push( + "slack-term", + ctx.Service.CreateNotifyMessage(ev.Channel), "", + notificator.UR_NORMAL, + ) + }() +} diff --git a/service/slack.go b/service/slack.go index 67f54a3..8ef1d53 100644 --- a/service/slack.go +++ b/service/slack.go @@ -306,8 +306,9 @@ func (s *SlackService) MarkAsRead(channelID int) { } } -// MarkAsUnread will set the channel as unread -func (s *SlackService) MarkAsUnread(channelID string) { +// FindChannel will loop over s.Channels to find the index where the +// channelID equals the ID +func (s *SlackService) FindChannel(channelID string) int { var index int for i, channel := range s.Channels { if channel.ID == channelID { @@ -315,9 +316,21 @@ func (s *SlackService) MarkAsUnread(channelID string) { break } } + return index +} + +// MarkAsUnread will set the channel as unread +func (s *SlackService) MarkAsUnread(channelID string) { + index := s.FindChannel(channelID) s.Channels[index].Notification = true } +// GetChannelName will return the name for a specific channelID +func (s *SlackService) GetChannelName(channelID string) string { + index := s.FindChannel(channelID) + return s.Channels[index].Name +} + // SendMessage will send a message to a particular channel func (s *SlackService) SendMessage(channelID int, message string) { @@ -519,6 +532,44 @@ func (s *SlackService) CreateMessageFromMessageEvent(message *slack.MessageEvent return msgs, nil } +// CheckNotifyMention check if the message event is either contains a +// mention or is posted on an IM channel +func (s *SlackService) CheckNotifyMention(ev *slack.MessageEvent) bool { + channel := s.Channels[s.FindChannel(ev.Channel)] + switch channel.Type { + case ChannelTypeIM: + return true + } + + // Mentions have the following format: + // <@U12345|erroneousboat> + // <@U12345> + r := regexp.MustCompile(`\<@(\w+\|*\w+)\>`) + matches := r.FindAllString(ev.Text, -1) + for _, match := range matches { + if strings.Contains(match, s.CurrentUserID) { + return true + } + } + + return false +} + +func (s *SlackService) CreateNotifyMessage(channelID string) string { + channel := s.Channels[s.FindChannel(channelID)] + + switch channel.Type { + case ChannelTypeChannel: + return fmt.Sprintf("Message received on channel: %s", channel.Name) + case ChannelTypeGroup: + return fmt.Sprintf("Message received in group: %s", channel.Name) + case ChannelTypeIM: + return fmt.Sprintf("Message received from: %s", channel.Name) + } + + return "" +} + // parseMessage will parse a message string and find and replace: // - emoji's // - mentions diff --git a/vendor/github.com/0xAX/notificator/.gitignore b/vendor/github.com/0xAX/notificator/.gitignore new file mode 100644 index 0000000..79d89ab --- /dev/null +++ b/vendor/github.com/0xAX/notificator/.gitignore @@ -0,0 +1,25 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test + +.idea diff --git a/vendor/github.com/0xAX/notificator/LICENSE b/vendor/github.com/0xAX/notificator/LICENSE new file mode 100644 index 0000000..015ead8 --- /dev/null +++ b/vendor/github.com/0xAX/notificator/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2014, 0xAX +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the {organization} nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/0xAX/notificator/README.md b/vendor/github.com/0xAX/notificator/README.md new file mode 100644 index 0000000..9e71f7a --- /dev/null +++ b/vendor/github.com/0xAX/notificator/README.md @@ -0,0 +1,49 @@ +notificator +=========================== + +Desktop notification with Golang for: + + * Windows with `growlnotify`; + * Mac OS X with `terminal-notifier` (if installed) or `osascript` (native, 10.9 Mavericks or Up.); + * Linux with `notify-send` for Gnome and `kdialog` for Kde. + +Usage +------ + +```go +package main + +import ( + "github.com/0xAX/notificator" +) + +var notify *notificator.Notificator + +func main() { + + notify = notificator.New(notificator.Options{ + DefaultIcon: "icon/default.png", + AppName: "My test App", + }) + + notify.Push("title", "text", "/home/user/icon.png", notificator.UR_CRITICAL) +} +``` + +TODO +----- + + * Add more options for different notificators. + +Сontribution +------------ + + * Fork; + * Make changes; + * Send pull request; + * Thank you. + +author +---------- + +[@0xAX](https://twitter.com/0xAX) diff --git a/vendor/github.com/0xAX/notificator/notification.go b/vendor/github.com/0xAX/notificator/notification.go new file mode 100644 index 0000000..918605a --- /dev/null +++ b/vendor/github.com/0xAX/notificator/notification.go @@ -0,0 +1,166 @@ +package notificator + +import ( + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" +) + +type Options struct { + DefaultIcon string + AppName string +} + +const ( + UR_NORMAL = "normal" + UR_CRITICAL = "critical" +) + +type notifier interface { + push(title string, text string, iconPath string) *exec.Cmd + pushCritical(title string, text string, iconPath string) *exec.Cmd +} + +type Notificator struct { + notifier notifier + defaultIcon string +} + +func (n Notificator) Push(title string, text string, iconPath string, urgency string) error { + icon := n.defaultIcon + + if iconPath != "" { + icon = iconPath + } + + if urgency == UR_CRITICAL { + return n.notifier.pushCritical(title, text, icon).Run() + } + + return n.notifier.push(title, text, icon).Run() + +} + +type osxNotificator struct { + AppName string +} + +func (o osxNotificator) push(title string, text string, iconPath string) *exec.Cmd { + + // Checks if terminal-notifier exists, and is accessible. + + term_notif := CheckTermNotif() + os_version_check := CheckMacOSVersion() + + // if terminal-notifier exists, use it. + // else, fall back to osascript. (Mavericks and later.) + + if term_notif == true { + return exec.Command("terminal-notifier", "-title", o.AppName, "-message", text, "-subtitle", title, "-appIcon", iconPath) + } else if os_version_check == true { + title = strings.Replace(title, `"`, `\"`, -1) + text = strings.Replace(text, `"`, `\"`, -1) + + notification := fmt.Sprintf("display notification \"%s\" with title \"%s\" subtitle \"%s\"", text, o.AppName, title) + return exec.Command("osascript", "-e", notification) + } + + // finally falls back to growlnotify. + + return exec.Command("growlnotify", "-n", o.AppName, "--image", iconPath, "-m", title) +} + +// Causes the notification to stick around until clicked. +func (o osxNotificator) pushCritical(title string, text string, iconPath string) *exec.Cmd { + + // same function as above... + + term_notif := CheckTermNotif() + os_version_check := CheckMacOSVersion() + + if term_notif == true { + // timeout set to 30 seconds, to show the importance of the notification + return exec.Command("terminal-notifier", "-title", o.AppName, "-message", text, "-subtitle", title, "-timeout", "30") + } else if os_version_check == true { + notification := fmt.Sprintf("display notification \"%s\" with title \"%s\" subtitle \"%s\"", text, o.AppName, title) + return exec.Command("osascript", "-e", notification) + } + + return exec.Command("growlnotify", "-n", o.AppName, "--image", iconPath, "-m", title) + +} + +type linuxNotificator struct{} + +func (l linuxNotificator) push(title string, text string, iconPath string) *exec.Cmd { + return exec.Command("notify-send", "-i", iconPath, title, text) +} + +// Causes the notification to stick around until clicked. +func (l linuxNotificator) pushCritical(title string, text string, iconPath string) *exec.Cmd { + return exec.Command("notify-send", "-i", iconPath, title, text, "-u", "critical") +} + +type windowsNotificator struct{} + +func (w windowsNotificator) push(title string, text string, iconPath string) *exec.Cmd { + return exec.Command("growlnotify", "/i:", iconPath, "/t:", title, text) +} + +// Causes the notification to stick around until clicked. +func (w windowsNotificator) pushCritical(title string, text string, iconPath string) *exec.Cmd { + return exec.Command("notify-send", "-i", iconPath, title, text, "/s", "true", "/p", "2") +} + +func New(o Options) *Notificator { + + var Notifier notifier + + switch runtime.GOOS { + + case "darwin": + Notifier = osxNotificator{AppName: o.AppName} + case "linux": + Notifier = linuxNotificator{} + case "windows": + Notifier = windowsNotificator{} + + } + + return &Notificator{notifier: Notifier, defaultIcon: o.DefaultIcon} +} + +// Helper function for macOS + +func CheckTermNotif() bool { + // Checks if terminal-notifier exists, and is accessible. + if err := exec.Command("which", "terminal-notifier").Run(); err != nil { + return false + } + // no error, so return true. (terminal-notifier exists) + return true +} + +func CheckMacOSVersion() bool { + // Checks if the version of macOS is 10.9 or Higher (osascript support for notifications.) + + cmd := exec.Command("sw_vers", "-productVersion") + check, _ := cmd.Output() + + version := strings.Split(strings.TrimSpace(string(check)), ".") + + // semantic versioning of macOS + + major, _ := strconv.Atoi(version[0]) + minor, _ := strconv.Atoi(version[1]) + + if major < 10 { + return false + } else if major == 10 && minor < 9 { + return false + } else { + return true + } +}