Merge branch 'v0.3.3-notify' into v0.3.3

* v0.3.3-notify:
  Implement configuration for desktop notifications
  Add desktop notifications
This commit is contained in:
erroneousboat 2018-04-07 11:01:22 +02:00
commit 4b4a0cb5f4
10 changed files with 402 additions and 22 deletions

8
Gopkg.lock generated
View File

@ -1,6 +1,12 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. # 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]] [[projects]]
name = "github.com/erroneousboat/termui" name = "github.com/erroneousboat/termui"
packages = ["."] packages = ["."]
@ -50,6 +56,6 @@
[solve-meta] [solve-meta]
analyzer-name = "dep" analyzer-name = "dep"
analyzer-version = 1 analyzer-version = 1
inputs-digest = "353a2a71e00ecf8dd6123a02828c450fa6d38472a98792d2d8a4cd6349900f11" inputs-digest = "6cdfd0125aad6371a6f4e75c7fc29507cee4a6001a6c68e06c7237066a31153a"
solver-name = "gps-cdcl" solver-name = "gps-cdcl"
solver-version = 1 solver-version = 1

View File

@ -1,4 +1,4 @@
Slack-Term slack-term
========== ==========
A [Slack](https://slack.com) client for your terminal. A [Slack](https://slack.com) client for your terminal.
@ -42,7 +42,12 @@ Setup
"slack_token": "yourslacktokenhere", "slack_token": "yourslacktokenhere",
// OPTIONAL: set the width of the sidebar (between 1 and 11), default is 1 // 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: // OPTIONAL: define custom key mappings, defaults are:
"key_map": { "key_map": {

View File

@ -9,9 +9,15 @@ import (
"github.com/erroneousboat/termui" "github.com/erroneousboat/termui"
) )
const (
NotifyAll = "all"
NotifyMention = "mention"
)
// Config is the definition of a Config struct // Config is the definition of a Config struct
type Config struct { type Config struct {
SlackToken string `json:"slack_token"` SlackToken string `json:"slack_token"`
Notify string `json:"notify"`
SidebarWidth int `json:"sidebar_width"` SidebarWidth int `json:"sidebar_width"`
MainWidth int `json:"-"` MainWidth int `json:"-"`
KeyMap map[string]keyMapping `json:"key_map"` KeyMap map[string]keyMapping `json:"key_map"`
@ -43,6 +49,13 @@ func NewConfig(filepath string) (*Config, error) {
cfg.MainWidth = 12 - cfg.SidebarWidth 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{ termui.ColorMap = map[string]termui.Attribute{
"fg": termui.StringToAttribute(cfg.Theme.View.Fg), "fg": termui.StringToAttribute(cfg.Theme.View.Fg),
"bg": termui.StringToAttribute(cfg.Theme.View.Bg), "bg": termui.StringToAttribute(cfg.Theme.View.Bg),
@ -59,6 +72,7 @@ func getDefaultConfig() Config {
return Config{ return Config{
SidebarWidth: 1, SidebarWidth: 1,
MainWidth: 11, MainWidth: 11,
Notify: "",
KeyMap: map[string]keyMapping{ KeyMap: map[string]keyMapping{
"command": { "command": {
"i": "mode-insert", "i": "mode-insert",

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
_ "net/http/pprof" _ "net/http/pprof"
"github.com/0xAX/notificator"
"github.com/erroneousboat/termui" "github.com/erroneousboat/termui"
termbox "github.com/nsf/termbox-go" termbox "github.com/nsf/termbox-go"
@ -26,6 +27,7 @@ type AppContext struct {
Config *config.Config Config *config.Config
Debug bool Debug bool
Mode string Mode string
Notify *notificator.Notificator
} }
// CreateAppContext creates an application context which can be passed // CreateAppContext creates an application context which can be passed
@ -92,5 +94,6 @@ func CreateAppContext(flgConfig string, flgDebug bool) (*AppContext, error) {
Config: config, Config: config,
Debug: flgDebug, Debug: flgDebug,
Mode: CommandMode, Mode: CommandMode,
Notify: notificator.New(notificator.Options{AppName: "slack-term"}),
}, nil }, nil
} }

View File

@ -6,15 +6,18 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/0xAX/notificator"
"github.com/erroneousboat/termui" "github.com/erroneousboat/termui"
"github.com/nlopes/slack" "github.com/nlopes/slack"
termbox "github.com/nsf/termbox-go" termbox "github.com/nsf/termbox-go"
"github.com/erroneousboat/slack-term/config"
"github.com/erroneousboat/slack-term/context" "github.com/erroneousboat/slack-term/context"
"github.com/erroneousboat/slack-term/views" "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, // actionMap binds specific action names to the function counterparts,
// these action names can then be used to bind them to specific keys // 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 // Add message to the selected channel
if ev.Channel == ctx.Service.Channels[ctx.View.Channels.SelectedChannel].ID { 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 // when attachments are added to message
for i := len(msg) - 1; i >= 0; i-- { for i := len(msg) - 1; i >= 0; i-- {
ctx.View.Chat.AddMessage( ctx.View.Chat.AddMessage(
@ -134,7 +137,7 @@ func messageHandler(ctx *context.AppContext) {
// window (tmux). But only create a notification when // window (tmux). But only create a notification when
// it comes from someone else but the current user. // it comes from someone else but the current user.
if ev.User != ctx.Service.CurrentUserID { if ev.User != ctx.Service.CurrentUserID {
actionNewMessage(ctx, ev.Channel) actionNewMessage(ctx, ev)
} }
case *slack.PresenceChangeEvent: case *slack.PresenceChangeEvent:
actionSetPresence(ctx, ev.User, ev.Presence) actionSetPresence(ctx, ev.User, ev.Presence)
@ -252,12 +255,12 @@ func actionSearch(ctx *context.AppContext, key rune) {
actionInput(ctx.View, key) actionInput(ctx.View, key)
go func() { go func() {
if timer != nil { if scrollTimer != nil {
timer.Stop() scrollTimer.Stop()
} }
timer = time.NewTimer(time.Second / 4) scrollTimer = time.NewTimer(time.Second / 4)
<-timer.C <-scrollTimer.C
// Only actually search when the time expires // Only actually search when the time expires
term := ctx.View.Input.GetText() term := ctx.View.Input.GetText()
@ -311,15 +314,15 @@ func actionGetMessages(ctx *context.AppContext) {
// the list without executing the actionChangeChannel event // the list without executing the actionChangeChannel event
func actionMoveCursorUpChannels(ctx *context.AppContext) { func actionMoveCursorUpChannels(ctx *context.AppContext) {
go func() { go func() {
if timer != nil { if scrollTimer != nil {
timer.Stop() scrollTimer.Stop()
} }
ctx.View.Channels.MoveCursorUp() ctx.View.Channels.MoveCursorUp()
termui.Render(ctx.View.Channels) termui.Render(ctx.View.Channels)
timer = time.NewTimer(time.Second / 4) scrollTimer = time.NewTimer(time.Second / 4)
<-timer.C <-scrollTimer.C
// Only actually change channel when the timer expires // Only actually change channel when the timer expires
actionChangeChannel(ctx) actionChangeChannel(ctx)
@ -331,15 +334,15 @@ func actionMoveCursorUpChannels(ctx *context.AppContext) {
// the list without executing the actionChangeChannel event // the list without executing the actionChangeChannel event
func actionMoveCursorDownChannels(ctx *context.AppContext) { func actionMoveCursorDownChannels(ctx *context.AppContext) {
go func() { go func() {
if timer != nil { if scrollTimer != nil {
timer.Stop() scrollTimer.Stop()
} }
ctx.View.Channels.MoveCursorDown() ctx.View.Channels.MoveCursorDown()
termui.Render(ctx.View.Channels) termui.Render(ctx.View.Channels)
timer = time.NewTimer(time.Second / 4) scrollTimer = time.NewTimer(time.Second / 4)
<-timer.C <-scrollTimer.C
// Only actually change channel when the timer expires // Only actually change channel when the timer expires
actionChangeChannel(ctx) actionChangeChannel(ctx)
@ -398,11 +401,24 @@ func actionChangeChannel(ctx *context.AppContext) {
termui.Render(ctx.View.Chat) termui.Render(ctx.View.Chat)
} }
func actionNewMessage(ctx *context.AppContext, channelID string) { // actionNewMessage will set the new message indicator for a channel, and
ctx.Service.MarkAsUnread(channelID) // 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()) ctx.View.Channels.SetChannels(ctx.Service.ChannelsToString())
termui.Render(ctx.View.Channels) termui.Render(ctx.View.Channels)
// Terminal bell
fmt.Print("\a") 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) { func actionSetPresence(ctx *context.AppContext, channelID string, presence string) {
@ -475,3 +491,21 @@ func getKeyString(e termbox.Event) string {
ek = pre + mod + k ek = pre + mod + k
return ek 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,
)
}()
}

View File

@ -306,8 +306,9 @@ func (s *SlackService) MarkAsRead(channelID int) {
} }
} }
// MarkAsUnread will set the channel as unread // FindChannel will loop over s.Channels to find the index where the
func (s *SlackService) MarkAsUnread(channelID string) { // channelID equals the ID
func (s *SlackService) FindChannel(channelID string) int {
var index int var index int
for i, channel := range s.Channels { for i, channel := range s.Channels {
if channel.ID == channelID { if channel.ID == channelID {
@ -315,9 +316,21 @@ func (s *SlackService) MarkAsUnread(channelID string) {
break 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 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 // SendMessage will send a message to a particular channel
func (s *SlackService) SendMessage(channelID int, message string) { func (s *SlackService) SendMessage(channelID int, message string) {
@ -519,6 +532,44 @@ func (s *SlackService) CreateMessageFromMessageEvent(message *slack.MessageEvent
return msgs, nil 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: // parseMessage will parse a message string and find and replace:
// - emoji's // - emoji's
// - mentions // - mentions

25
vendor/github.com/0xAX/notificator/.gitignore generated vendored Normal file
View File

@ -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

27
vendor/github.com/0xAX/notificator/LICENSE generated vendored Normal file
View File

@ -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.

49
vendor/github.com/0xAX/notificator/README.md generated vendored Normal file
View File

@ -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)

166
vendor/github.com/0xAX/notificator/notification.go generated vendored Normal file
View File

@ -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
}
}