Initial Commit

This commit is contained in:
Brian Buller 2017-09-26 11:04:10 -05:00
parent 65c6adcb0e
commit d934b91188
14 changed files with 3653 additions and 1 deletions

2
.gitignore vendored
View File

@ -24,3 +24,5 @@ _testmain.go
*.test
*.prof
# The binary
sluice

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016 J.P.H. Bruins Slot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

139
README.md
View File

@ -1,3 +1,140 @@
# sluice
A terminal Slack client
A [Slack](https://slack.com) client for your terminal.
![Screenshot](/screenshot.png?raw=true)
Installation
------------
#### Binary installation
[Download](https://github.com/erroneousboat/slack-term/releases) a
compatible binary for your system. For convenience place `slack-term` in a
directory where you can access it from the command line. Usually this is
`/usr/local/bin`.
```bash
$ mv slack-term /usr/local/bin
```
#### Via Go
If you want you can also get `slack-term` via Go:
```bash
$ go get -u github.com/erroneousboat/slack-term
```
Setup
-----
1. Get a slack token, click [here](https://api.slack.com/docs/oauth-test-tokens)
2. Create a `slack-term.json` file, place it in your home directory. Below is
an an example file, you can leave out the `OPTIONAL` parts, you are only
required to specify a `slack_token`. Remember that your file should be
a valid json file so don't forget to remove the comments.
```javascript
{
"slack_token": "yourslacktokenhere",
// OPTIONAL: add the following to use light theme, default is dark
"theme": "light",
// OPTIONAL: set the width of the sidebar (between 1 and 11), default is 1
"sidebar_width": 3,
// OPTIONAL: define custom key mappings, defaults are:
"key_map": {
"command": {
"i": "mode-insert",
"k": "channel-up",
"j": "channel-down",
"g": "channel-top",
"G": "channel-bottom",
"<previous>": "chat-up",
"C-b": "chat-up",
"C-u": "chat-up",
"<next>": "chat-down",
"C-f": "chat-down",
"C-d": "chat-down",
"q": "quit",
"<f1>": "help"
},
"insert": {
"<left>": "cursor-left",
"<right>": "cursor-right",
"<enter>": "send",
"<escape>": "mode-command",
"<backspace>": "backspace",
"C-8": "backspace",
"<delete>": "delete",
"<space>": "space"
},
"search": {
"<left>": "cursor-left",
"<right>": "cursor-right",
"<escape>": "clear-input",
"<enter>": "clear-input",
"<backspace>": "backspace",
"C-8": "backspace",
"<delete>": "delete",
"<space>": "space"
}
}
}
```
Usage
-----
When everything is setup correctly you can run `slack-term` with the following
command:
```bash
$ slack-term
```
You can also specify the location of the config file, this will give you
the possibility to run several instances of `slack-term` with different
accounts.
```bash
$ slack-term -config [path-to-config-file]
```
Default Key Mapping
-------------------
Below are the default key-mapping for `slack-term`, you can change them
in your `slack-term.json` file.
| mode | key | action |
|---------|-----------|----------------------------|
| command | `i` | insert mode |
| command | `/` | search mode |
| command | `k` | move channel cursor up |
| command | `j` | move channel cursor down |
| command | `g` | move channel cursor top |
| command | `G` | move channel cursor bottom |
| command | `pg-up` | scroll chat pane up |
| command | `ctrl-b` | scroll chat pane up |
| command | `ctrl-u` | scroll chat pane up |
| command | `pg-down` | scroll chat pane down |
| command | `ctrl-f` | scroll chat pane down |
| command | `ctrl-d` | scroll chat pane down |
| command | `q` | quit |
| command | `f1` | help |
| insert | `left` | move input cursor left |
| insert | `right` | move input cursor right |
| insert | `enter` | send message |
| insert | `esc` | command mode |
| search | `esc` | command mode |
| search | `enter` | command mode |
Originally forked from erroneousboat's slack-term:
https://github.com/erroneousboat/slack-term

352
channels.go Normal file
View File

@ -0,0 +1,352 @@
package main
import (
"fmt"
"strings"
"github.com/gizak/termui"
)
const (
IconOnline = "●"
IconOffline = "○"
IconChannel = "#"
IconGroup = "☰"
IconIM = "●"
IconNotification = "🞷"
PresenceAway = "away"
PresenceActive = "active"
)
// Channels is the definition of a Channels component
type Channels struct {
List *termui.List
SelectedChannel int // index of which channel is selected from the List
Offset int // from what offset are channels rendered
CursorPosition int // the y position of the 'cursor'
}
// CreateChannels is the constructor for the Channels component
func CreateChannels(svc *SlackService, inputHeight int) *Channels {
channels := &Channels{
List: termui.NewList(),
}
channels.List.BorderLabel = "Channels"
channels.List.Height = termui.TermHeight() - inputHeight
channels.SelectedChannel = 0
channels.Offset = 0
channels.CursorPosition = channels.List.InnerBounds().Min.Y
channels.GetChannels(svc)
channels.SetPresenceForIMChannels(svc)
return channels
}
// Buffer implements interface termui.Bufferer
func (c *Channels) Buffer() termui.Buffer {
buf := c.List.Buffer()
for i, item := range c.List.Items[c.Offset:] {
y := c.List.InnerBounds().Min.Y + i
if y > c.List.InnerBounds().Max.Y-1 {
break
}
var cells []termui.Cell
if y == c.CursorPosition {
cells = termui.DefaultTxBuilder.Build(
item, c.List.ItemBgColor, c.List.ItemFgColor)
} else {
cells = termui.DefaultTxBuilder.Build(
item, c.List.ItemFgColor, c.List.ItemBgColor)
}
cells = termui.DTrimTxCls(cells, c.List.InnerWidth())
x := 0
for _, cell := range cells {
width := cell.Width()
buf.Set(c.List.InnerBounds().Min.X+x, y, cell)
x += width
}
// When not at the end of the pane fill it up empty characters
for x < c.List.InnerBounds().Max.X-1 {
if y == c.CursorPosition {
buf.Set(x+1, y,
termui.Cell{
Ch: ' ',
Fg: c.List.ItemBgColor,
Bg: c.List.ItemFgColor,
},
)
} else {
buf.Set(
x+1, y,
termui.Cell{
Ch: ' ',
Fg: c.List.ItemFgColor,
Bg: c.List.ItemBgColor,
},
)
}
x++
}
}
return buf
}
// GetHeight implements interface termui.GridBufferer
func (c *Channels) GetHeight() int {
return c.List.Block.GetHeight()
}
// SetWidth implements interface termui.GridBufferer
func (c *Channels) SetWidth(w int) {
c.List.SetWidth(w)
}
// SetX implements interface termui.GridBufferer
func (c *Channels) SetX(x int) {
c.List.SetX(x)
}
// SetY implements interface termui.GridBufferer
func (c *Channels) SetY(y int) {
c.List.SetY(y)
}
// GetChannels will get all available channels from the SlackService
func (c *Channels) GetChannels(svc *SlackService) {
for _, slackChan := range svc.GetChannels() {
label := setChannelLabel(slackChan, false)
c.List.Items = append(c.List.Items, label)
}
}
// SetPresenceForIMChannels this will set the correct icon for
// IM channels for when they're online of away
func (c *Channels) SetPresenceForIMChannels(svc *SlackService) {
for _, slackChan := range svc.GetChannels() {
if slackChan.Type == ChannelTypeIM {
presence, err := svc.GetUserPresence(slackChan.UserID)
if err != nil {
continue
}
c.SetPresence(svc, slackChan.UserID, presence)
}
}
}
// SetSelectedChannel sets the SelectedChannel given the index
func (c *Channels) SetSelectedChannel(index int) {
c.SelectedChannel = index
}
// MoveCursorUp will decrease the SelectedChannel by 1
func (c *Channels) MoveCursorUp() {
if c.SelectedChannel > 0 {
c.SetSelectedChannel(c.SelectedChannel - 1)
c.ScrollUp()
c.ClearNewMessageIndicator()
}
}
// MoveCursorDown will increase the SelectedChannel by 1
func (c *Channels) MoveCursorDown() {
if c.SelectedChannel < len(c.List.Items)-1 {
c.SetSelectedChannel(c.SelectedChannel + 1)
c.ScrollDown()
c.ClearNewMessageIndicator()
}
}
// MoveCursorTop will move the cursor to the top of the channels
func (c *Channels) MoveCursorTop() {
c.SetSelectedChannel(0)
c.CursorPosition = c.List.InnerBounds().Min.Y
c.Offset = 0
}
// MoveCursorBottom will move the cursor to the bottom of the channels
func (c *Channels) MoveCursorBottom() {
c.SetSelectedChannel(len(c.List.Items) - 1)
offset := len(c.List.Items) - (c.List.InnerBounds().Max.Y - 1)
if offset < 0 {
c.Offset = 0
c.CursorPosition = c.SelectedChannel + 1
} else {
c.Offset = offset
c.CursorPosition = c.List.InnerBounds().Max.Y - 1
}
}
// ScrollUp enables us to scroll through the channel list when it overflows
func (c *Channels) ScrollUp() {
// Is cursor at the top of the channel view?
if c.CursorPosition == c.List.InnerBounds().Min.Y {
if c.Offset > 0 {
c.Offset--
}
} else {
c.CursorPosition--
}
}
// ScrollDown enables us to scroll through the channel list when it overflows
func (c *Channels) ScrollDown() {
// Is the cursor at the bottom of the channel view?
if c.CursorPosition == c.List.InnerBounds().Max.Y-1 {
if c.Offset < len(c.List.Items)-1 {
c.Offset++
}
} else {
c.CursorPosition++
}
}
// Search will search through the channels to find a channel,
// when a match has been found the selected channel will then
// be the channel that has been found
func (c *Channels) Search(term string) {
for i, item := range c.List.Items {
if strings.Contains(item, term) {
// The new position
newPos := i
// Is the new position in range of the current view?
minRange := c.Offset
maxRange := c.Offset + (c.List.InnerBounds().Max.Y - 2)
if newPos < minRange {
// newPos is above, we need to scroll up.
c.SetSelectedChannel(i)
// How much do we need to scroll to get it into range?
c.Offset = c.Offset - (minRange - newPos)
} else if newPos > maxRange {
// newPos is below, we need to scroll down
c.SetSelectedChannel(i)
// How much do we need to scroll to get it into range?
c.Offset = c.Offset + (newPos - maxRange)
} else {
// newPos is inside range
c.SetSelectedChannel(i)
}
// Set cursor to correct position
c.CursorPosition = (newPos - c.Offset) + 1
break
}
}
}
// SetNotification will be called when a new message arrives and will
// render an notification icon in front of the channel name
func (c *Channels) SetNotification(svc *SlackService, channelID string) {
// Get the correct Channel from svc.Channels
var index int
for i, channel := range svc.Channels {
if channelID == channel.ID {
index = i
break
}
}
if !strings.Contains(c.List.Items[index], IconNotification) {
// The order of svc.Channels relates to the order of
// List.Items, index will be the index of the channel
c.List.Items[index] = fmt.Sprintf(
"%s %s", IconNotification, strings.TrimSpace(c.List.Items[index]),
)
}
// Play terminal bell sound
fmt.Print("\a")
}
// ClearNewMessageIndicator will remove the notification icon in front of
// a channel that received a new message. This will happen as one will
// move up or down the cursor for Channels
func (c *Channels) ClearNewMessageIndicator() {
channelName := strings.Split(
c.List.Items[c.SelectedChannel],
fmt.Sprintf("%s ", IconNotification),
)
if len(channelName) > 1 {
c.List.Items[c.SelectedChannel] = fmt.Sprintf(" %s", channelName[1])
} else {
c.List.Items[c.SelectedChannel] = channelName[0]
}
}
// SetReadMark will send the ReadMark event on the service
func (c *Channels) SetReadMark(svc *SlackService) {
svc.SetChannelReadMark(svc.SlackChannels[c.SelectedChannel])
}
// SetPresence will set the correct icon for a IM Channel
func (c *Channels) SetPresence(svc *SlackService, userID string, presence string) {
// Get the correct Channel from svc.Channels
var index int
for i, channel := range svc.Channels {
if userID == channel.UserID {
index = i
break
}
}
switch presence {
case PresenceActive:
c.List.Items[index] = strings.Replace(
c.List.Items[index], IconOffline, IconOnline, 1,
)
case PresenceAway:
c.List.Items[index] = strings.Replace(
c.List.Items[index], IconOnline, IconOffline, 1,
)
default:
c.List.Items[index] = strings.Replace(
c.List.Items[index], IconOnline, IconOffline, 1,
)
}
}
// setChannelLabel will set the label of the channel, meaning, how it
// is displayed on screen. Based on the type, different icons are
// shown, as well as an optional notification icon.
func setChannelLabel(channel Channel, notification bool) string {
var prefix string
if notification {
prefix = IconNotification
} else {
prefix = " "
}
var label string
switch channel.Type {
case ChannelTypeChannel:
label = fmt.Sprintf("%s %s %s", prefix, IconChannel, channel.Name)
case ChannelTypeGroup:
label = fmt.Sprintf("%s %s %s", prefix, IconGroup, channel.Name)
case ChannelTypeIM:
label = fmt.Sprintf("%s %s %s", prefix, IconIM, channel.Name)
}
return label
}

250
chat.go Normal file
View File

@ -0,0 +1,250 @@
package main
import (
"fmt"
"html"
"sort"
"strings"
"github.com/gizak/termui"
)
// Chat is the definition of a Chat component
type Chat struct {
List *termui.List
Offset int
}
// CreateChat is the constructor for the Chat struct
func CreateChat(svc *SlackService, inputHeight int, selectedSlackChannel interface{}, selectedChannel Channel) *Chat {
chat := &Chat{
List: termui.NewList(),
Offset: 0,
}
chat.List.Height = termui.TermHeight() - inputHeight
chat.List.Overflow = "wrap"
chat.GetMessages(svc, selectedSlackChannel)
chat.SetBorderLabel(selectedChannel)
return chat
}
// Buffer implements interface termui.Bufferer
func (c *Chat) Buffer() termui.Buffer {
// Build cells, after every item put a newline
cells := termui.DefaultTxBuilder.Build(
strings.Join(c.List.Items, "\n"),
c.List.ItemFgColor, c.List.ItemBgColor,
)
// We will create an array of Line structs, this allows us
// to more easily render the items in a list. We will range
// over the cells we've created and create a Line within
// the bounds of the Chat pane
type Line struct {
cells []termui.Cell
}
lines := []Line{}
line := Line{}
x := 0
for _, cell := range cells {
if cell.Ch == '\n' {
lines = append(lines, line)
line = Line{}
x = 0
continue
}
if x+cell.Width() > c.List.InnerBounds().Dx() {
lines = append(lines, line)
line = Line{}
x = 0
}
line.cells = append(line.cells, cell)
x++
}
lines = append(lines, line)
// We will print lines bottom up, it will loop over the lines
// backwards and for every line it'll set the cell in that line.
// Offset is the number which allows us to begin printing the
// line above the last line.
buf := c.List.Buffer()
linesHeight := len(lines)
paneMinY := c.List.InnerBounds().Min.Y
paneMaxY := c.List.InnerBounds().Max.Y
currentY := paneMaxY - 1
for i := (linesHeight - 1) - c.Offset; i >= 0; i-- {
if currentY < paneMinY {
break
}
x := c.List.InnerBounds().Min.X
for _, cell := range lines[i].cells {
buf.Set(x, currentY, cell)
x += cell.Width()
}
// When we're not at the end of the pane, fill it up
// with empty characters
for x < c.List.InnerBounds().Max.X {
buf.Set(
x, currentY,
termui.Cell{
Ch: ' ',
Fg: c.List.ItemFgColor,
Bg: c.List.ItemBgColor,
},
)
x++
}
currentY--
}
// If the space above currentY is empty we need to fill
// it up with blank lines, otherwise the List object will
// render the items top down, and the result will mix.
for currentY >= paneMinY {
x := c.List.InnerBounds().Min.X
for x < c.List.InnerBounds().Max.X {
buf.Set(
x, currentY,
termui.Cell{
Ch: ' ',
Fg: c.List.ItemFgColor,
Bg: c.List.ItemBgColor,
},
)
x++
}
currentY--
}
return buf
}
// GetHeight implements interface termui.GridBufferer
func (c *Chat) GetHeight() int {
return c.List.Block.GetHeight()
}
// SetWidth implements interface termui.GridBufferer
func (c *Chat) SetWidth(w int) {
c.List.SetWidth(w)
}
// SetX implements interface termui.GridBufferer
func (c *Chat) SetX(x int) {
c.List.SetX(x)
}
// SetY implements interface termui.GridBufferer
func (c *Chat) SetY(y int) {
c.List.SetY(y)
}
// GetMessages will get an array of strings for a specific channel which will
// contain messages in turn all these messages will be added to List.Items
func (c *Chat) GetMessages(svc *SlackService, channel interface{}) {
// Get the count of message that fit in the pane
count := c.List.InnerBounds().Max.Y - c.List.InnerBounds().Min.Y
messages := svc.GetMessages(channel, count)
for _, message := range messages {
c.AddMessage(message)
}
}
// AddMessage adds a single message to List.Items
func (c *Chat) AddMessage(message string) {
c.List.Items = append(c.List.Items, html.UnescapeString(message))
}
// ClearMessages clear the List.Items
func (c *Chat) ClearMessages() {
c.List.Items = []string{}
}
// ScrollUp will render the chat messages based on the Offset of the Chat
// pane.
//
// Offset is 0 when scrolled down. (we loop backwards over the array, so we
// start with rendering last item in the list at the maximum y of the Chat
// pane). Increasing the Offset will thus result in substracting the offset
// from the len(Chat.List.Items).
func (c *Chat) ScrollUp() {
c.Offset = c.Offset + 10
// Protect overscrolling
if c.Offset > len(c.List.Items)-1 {
c.Offset = len(c.List.Items) - 1
}
}
// ScrollDown will render the chat messages based on the Offset of the Chat
// pane.
//
// Offset is 0 when scrolled down. (we loop backwards over the array, so we
// start with rendering last item in the list at the maximum y of the Chat
// pane). Increasing the Offset will thus result in substracting the offset
// from the len(Chat.List.Items).
func (c *Chat) ScrollDown() {
c.Offset = c.Offset - 10
// Protect overscrolling
if c.Offset < 0 {
c.Offset = 0
}
}
// SetBorderLabel will set Label of the Chat pane to the specified string
func (c *Chat) SetBorderLabel(channel Channel) {
var channelName string
if channel.Topic != "" {
channelName = fmt.Sprintf("%s - %s",
channel.Name,
channel.Topic,
)
} else {
channelName = channel.Name
}
c.List.BorderLabel = channelName
}
// Help shows the usage and key bindings in the chat pane
func (c *Chat) Help(cfg *Config) {
help := []string{
"slack-term - slack client for your terminal",
"",
"USAGE:",
" slack-term -config [path-to-config]",
"",
"KEY BINDINGS:",
"",
}
for mode, mapping := range cfg.KeyMap {
help = append(help, fmt.Sprintf(" %s", strings.ToUpper(mode)))
help = append(help, "")
var keys []string
for k := range mapping {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
help = append(help, fmt.Sprintf(" %-12s%-15s", k, mapping[k]))
}
help = append(help, "")
}
c.List.Items = help
}

99
config.go Normal file
View File

@ -0,0 +1,99 @@
package main
import (
"encoding/json"
"errors"
"os"
"github.com/gizak/termui"
)
// Config is the definition of a Config struct
type Config struct {
SlackToken string `json:"slack_token"`
Theme string `json:"theme"`
SidebarWidth int `json:"sidebar_width"`
MainWidth int `json:"-"`
KeyMap map[string]keyMapping `json:"key_map"`
}
type keyMapping map[string]string
// NewConfig loads the config file and returns a Config struct
func NewConfig(filepath string) (*Config, error) {
cfg := Config{
Theme: "dark",
SidebarWidth: 1,
MainWidth: 11,
KeyMap: map[string]keyMapping{
"command": {
"i": "mode-insert",
"/": "mode-search",
"k": "channel-up",
"j": "channel-down",
"g": "channel-top",
"G": "channel-bottom",
"<previous>": "chat-up",
"C-b": "chat-up",
"C-u": "chat-up",
"<next>": "chat-down",
"C-f": "chat-down",
"C-d": "chat-down",
"q": "quit",
"<f1>": "help",
},
"insert": {
"<left>": "cursor-left",
"<right>": "cursor-right",
"<enter>": "send",
"<escape>": "mode-command",
"<backspace>": "backspace",
"C-8": "backspace",
"<delete>": "delete",
"<space>": "space",
},
"search": {
"<left>": "cursor-left",
"<right>": "cursor-right",
"<escape>": "clear-input",
"<enter>": "clear-input",
"<backspace>": "backspace",
"C-8": "backspace",
"<delete>": "delete",
"<space>": "space",
},
},
}
file, err := os.Open(filepath)
if err != nil {
return &cfg, err
}
if err := json.NewDecoder(file).Decode(&cfg); err != nil {
return &cfg, err
}
if cfg.SlackToken == "" {
return &cfg, errors.New("couldn't find 'slack_token' parameter")
}
if cfg.SidebarWidth < 1 || cfg.SidebarWidth > 11 {
return &cfg, errors.New("please specify the 'sidebar_width' between 1 and 11")
}
cfg.MainWidth = 12 - cfg.SidebarWidth
if cfg.Theme == "light" {
termui.ColorMap = map[string]termui.Attribute{
"fg": termui.ColorBlack,
"bg": termui.ColorWhite,
"border.fg": termui.ColorBlack,
"label.fg": termui.ColorBlue,
"par.fg": termui.ColorYellow,
"par.label.bg": termui.ColorWhite,
}
}
return &cfg, nil
}

45
context.go Normal file
View File

@ -0,0 +1,45 @@
package main
import (
"github.com/gizak/termui"
termbox "github.com/nsf/termbox-go"
)
const (
CommandMode = "command"
InsertMode = "insert"
SearchMode = "search"
)
type AppContext struct {
EventQueue chan termbox.Event
Service *SlackService
Body *termui.Grid
View *View
Config *Config
Mode string
}
// CreateAppContext creates an application context which can be passed
// and referenced througout the application
func CreateAppContext(flgConfig string) (*AppContext, error) {
// Load config
config, err := NewConfig(flgConfig)
if err != nil {
return nil, err
}
// Create Service
svc := NewSlackService(config.SlackToken)
// Create ChatView
view := CreateChatView(svc)
return &AppContext{
EventQueue: make(chan termbox.Event, 20),
Service: svc,
View: view,
Config: config,
Mode: CommandMode,
}, nil
}

1511
emoji.go Normal file

File diff suppressed because it is too large Load Diff

400
event.go Normal file
View File

@ -0,0 +1,400 @@
package main
import (
"os"
"strconv"
"time"
"github.com/gizak/termui"
"github.com/nlopes/slack"
termbox "github.com/nsf/termbox-go"
)
var timer *time.Timer
// actionMap binds specific action names to the function counterparts,
// these action names can then be used to bind them to specific keys
// in the Config.
var actionMap = map[string]func(*AppContext){
"space": actionSpace,
"backspace": actionBackSpace,
"delete": actionDelete,
"cursor-right": actionMoveCursorRight,
"cursor-left": actionMoveCursorLeft,
"send": actionSend,
"quit": actionQuit,
"mode-insert": actionInsertMode,
"mode-command": actionCommandMode,
"mode-search": actionSearchMode,
"clear-input": actionClearInput,
"channel-up": actionMoveCursorUpChannels,
"channel-down": actionMoveCursorDownChannels,
"channel-top": actionMoveCursorTopChannels,
"channel-bottom": actionMoveCursorBottomChannels,
"chat-up": actionScrollUpChat,
"chat-down": actionScrollDownChat,
"help": actionHelp,
}
func RegisterEventHandlers(ctx *AppContext) {
eventHandler(ctx)
messageHandler(ctx)
}
func eventHandler(ctx *AppContext) {
go func() {
for {
ctx.EventQueue <- termbox.PollEvent()
}
}()
go func() {
for {
ev := <-ctx.EventQueue
handleTermboxEvents(ctx, ev)
handleMoreTermboxEvents(ctx, ev)
}
}()
}
func handleTermboxEvents(ctx *AppContext, ev termbox.Event) bool {
switch ev.Type {
case termbox.EventKey:
actionKeyEvent(ctx, ev)
case termbox.EventResize:
actionResizeEvent(ctx, ev)
}
return true
}
func handleMoreTermboxEvents(ctx *AppContext, ev termbox.Event) bool {
for {
select {
case ev := <-ctx.EventQueue:
ok := handleTermboxEvents(ctx, ev)
if !ok {
return false
}
default:
return true
}
}
}
func messageHandler(ctx *AppContext) {
go func() {
for {
select {
case msg := <-ctx.Service.RTM.IncomingEvents:
switch ev := msg.Data.(type) {
case *slack.MessageEvent:
// Construct message
msg := ctx.Service.CreateMessageFromMessageEvent(ev)
// Add message to the selected channel
if ev.Channel == ctx.Service.Channels[ctx.View.Channels.SelectedChannel].ID {
// reverse order of messages, mainly done
// when attachments are added to message
for i := len(msg) - 1; i >= 0; i-- {
ctx.View.Chat.AddMessage(msg[i])
}
termui.Render(ctx.View.Chat)
// TODO: set Chat.Offset to 0, to automatically scroll
// down?
}
// Set new message indicator for channel, I'm leaving
// this here because I also want to be notified when
// I'm currently in a channel but not in the terminal
// 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)
}
case *slack.PresenceChangeEvent:
actionSetPresence(ctx, ev.User, ev.Presence)
}
}
}
}()
}
func actionKeyEvent(ctx *AppContext, ev termbox.Event) {
keyStr := getKeyString(ev)
// Get the action name (actionStr) from the key that
// has been pressed. If this is found try to uncover
// the associated function with this key and execute
// it.
actionStr, ok := ctx.Config.KeyMap[ctx.Mode][keyStr]
if ok {
action, ok := actionMap[actionStr]
if ok {
action(ctx)
}
} else {
if ctx.Mode == InsertMode && ev.Ch != 0 {
actionInput(ctx.View, ev.Ch)
} else if ctx.Mode == SearchMode && ev.Ch != 0 {
actionSearch(ctx, ev.Ch)
}
}
}
func actionResizeEvent(ctx *AppContext, ev termbox.Event) {
termui.Body.Width = termui.TermWidth()
termui.Body.Align()
termui.Render(termui.Body)
}
func actionInput(view *View, key rune) {
view.Input.Insert(key)
termui.Render(view.Input)
}
func actionClearInput(ctx *AppContext) {
// Clear input
ctx.View.Input.Clear()
ctx.View.Refresh()
// Set command mode
actionCommandMode(ctx)
}
func actionSpace(ctx *AppContext) {
actionInput(ctx.View, ' ')
}
func actionBackSpace(ctx *AppContext) {
ctx.View.Input.Backspace()
termui.Render(ctx.View.Input)
}
func actionDelete(ctx *AppContext) {
ctx.View.Input.Delete()
termui.Render(ctx.View.Input)
}
func actionMoveCursorRight(ctx *AppContext) {
ctx.View.Input.MoveCursorRight()
termui.Render(ctx.View.Input)
}
func actionMoveCursorLeft(ctx *AppContext) {
ctx.View.Input.MoveCursorLeft()
termui.Render(ctx.View.Input)
}
func actionSend(ctx *AppContext) {
if !ctx.View.Input.IsEmpty() {
// Clear message before sending, to combat
// quick succession of actionSend
message := ctx.View.Input.GetText()
ctx.View.Input.Clear()
ctx.View.Refresh()
ctx.View.Input.SendMessage(
ctx.Service,
ctx.Service.Channels[ctx.View.Channels.SelectedChannel].ID,
message,
)
}
}
func actionSearch(ctx *AppContext, key rune) {
go func() {
if timer != nil {
timer.Stop()
}
actionInput(ctx.View, key)
timer = time.NewTimer(time.Second / 4)
<-timer.C
term := ctx.View.Input.GetText()
ctx.View.Channels.Search(term)
actionChangeChannel(ctx)
}()
}
// actionQuit will exit the program by using os.Exit, this is
// done because we are using a custom termui EvtStream. Which
// we won't be able to call termui.StopLoop() on. See main.go
// for the customEvtStream and why this is done.
func actionQuit(ctx *AppContext) {
termbox.Close()
os.Exit(0)
}
func actionInsertMode(ctx *AppContext) {
ctx.Mode = InsertMode
ctx.View.Mode.Par.Text = "INSERT"
termui.Render(ctx.View.Mode)
}
func actionCommandMode(ctx *AppContext) {
ctx.Mode = CommandMode
ctx.View.Mode.Par.Text = "NORMAL"
termui.Render(ctx.View.Mode)
}
func actionSearchMode(ctx *AppContext) {
ctx.Mode = SearchMode
ctx.View.Mode.Par.Text = "SEARCH"
termui.Render(ctx.View.Mode)
}
func actionGetMessages(ctx *AppContext) {
ctx.View.Chat.GetMessages(
ctx.Service,
ctx.Service.Channels[ctx.View.Channels.SelectedChannel],
)
termui.Render(ctx.View.Chat)
}
func actionMoveCursorUpChannels(ctx *AppContext) {
go func() {
if timer != nil {
timer.Stop()
}
ctx.View.Channels.MoveCursorUp()
termui.Render(ctx.View.Channels)
timer = time.NewTimer(time.Second / 4)
<-timer.C
actionChangeChannel(ctx)
}()
}
func actionMoveCursorDownChannels(ctx *AppContext) {
go func() {
if timer != nil {
timer.Stop()
}
ctx.View.Channels.MoveCursorDown()
termui.Render(ctx.View.Channels)
timer = time.NewTimer(time.Second / 4)
<-timer.C
actionChangeChannel(ctx)
}()
}
func actionMoveCursorTopChannels(ctx *AppContext) {
ctx.View.Channels.MoveCursorTop()
actionChangeChannel(ctx)
}
func actionMoveCursorBottomChannels(ctx *AppContext) {
ctx.View.Channels.MoveCursorBottom()
actionChangeChannel(ctx)
}
func actionChangeChannel(ctx *AppContext) {
// Clear messages from Chat pane
ctx.View.Chat.ClearMessages()
// Get message for the new channel
ctx.View.Chat.GetMessages(
ctx.Service,
ctx.Service.SlackChannels[ctx.View.Channels.SelectedChannel],
)
// Set channel name for the Chat pane
ctx.View.Chat.SetBorderLabel(
ctx.Service.Channels[ctx.View.Channels.SelectedChannel],
)
// Set read mark
ctx.View.Channels.SetReadMark(ctx.Service)
termui.Render(ctx.View.Channels)
termui.Render(ctx.View.Chat)
}
func actionNewMessage(ctx *AppContext, channelID string) {
ctx.View.Channels.SetNotification(ctx.Service, channelID)
termui.Render(ctx.View.Channels)
}
func actionSetPresence(ctx *AppContext, channelID string, presence string) {
ctx.View.Channels.SetPresence(ctx.Service, channelID, presence)
termui.Render(ctx.View.Channels)
}
func actionScrollUpChat(ctx *AppContext) {
ctx.View.Chat.ScrollUp()
termui.Render(ctx.View.Chat)
}
func actionScrollDownChat(ctx *AppContext) {
ctx.View.Chat.ScrollDown()
termui.Render(ctx.View.Chat)
}
func actionHelp(ctx *AppContext) {
ctx.View.Chat.Help(ctx.Config)
termui.Render(ctx.View.Chat)
}
// GetKeyString will return a string that resembles the key event from
// termbox. This is blatanly copied from termui because it is an unexported
// function.
//
// See:
// - https://github.com/gizak/termui/blob/a7e3aeef4cdf9fa2edb723b1541cb69b7bb089ea/events.go#L31-L72
// - https://github.com/nsf/termbox-go/blob/master/api_common.go
func getKeyString(e termbox.Event) string {
var ek string
k := string(e.Ch)
pre := ""
mod := ""
if e.Mod == termbox.ModAlt {
mod = "M-"
}
if e.Ch == 0 {
if e.Key > 0xFFFF-12 {
k = "<f" + strconv.Itoa(0xFFFF-int(e.Key)+1) + ">"
} else if e.Key > 0xFFFF-25 {
ks := []string{"<insert>", "<delete>", "<home>", "<end>", "<previous>", "<next>", "<up>", "<down>", "<left>", "<right>"}
k = ks[0xFFFF-int(e.Key)-12]
}
if e.Key <= 0x7F {
pre = "C-"
k = string('a' - 1 + int(e.Key))
kmap := map[termbox.Key][2]string{
termbox.KeyCtrlSpace: {"C-", "<space>"},
termbox.KeyBackspace: {"", "<backspace>"},
termbox.KeyTab: {"", "<tab>"},
termbox.KeyEnter: {"", "<enter>"},
termbox.KeyEsc: {"", "<escape>"},
termbox.KeyCtrlBackslash: {"C-", "\\"},
termbox.KeyCtrlSlash: {"C-", "/"},
termbox.KeySpace: {"", "<space>"},
termbox.KeyCtrl8: {"C-", "8"},
}
if sk, ok := kmap[e.Key]; ok {
pre = sk[0]
k = sk[1]
}
}
}
ek = pre + mod + k
return ek
}

135
input.go Normal file
View File

@ -0,0 +1,135 @@
package main
import (
"github.com/gizak/termui"
)
// Input is the definition of an Input component
type Input struct {
Par *termui.Par
Text []rune
CursorPosition int
}
// CreateInput is the constructor of the Input struct
func CreateInput() *Input {
input := &Input{
Par: termui.NewPar(""),
Text: make([]rune, 0),
CursorPosition: 0,
}
input.Par.Height = 3
return input
}
// Buffer implements interface termui.Bufferer
func (i *Input) Buffer() termui.Buffer {
buf := i.Par.Buffer()
// Set visible cursor
char := buf.At(i.Par.InnerX()+i.CursorPosition, i.Par.Block.InnerY())
buf.Set(
i.Par.InnerX()+i.CursorPosition,
i.Par.Block.InnerY(),
termui.Cell{
Ch: char.Ch,
Fg: i.Par.TextBgColor,
Bg: i.Par.TextFgColor,
},
)
return buf
}
// GetHeight implements interface termui.GridBufferer
func (i *Input) GetHeight() int {
return i.Par.Block.GetHeight()
}
// SetWidth implements interface termui.GridBufferer
func (i *Input) SetWidth(w int) {
i.Par.SetWidth(w)
}
// SetX implements interface termui.GridBufferer
func (i *Input) SetX(x int) {
i.Par.SetX(x)
}
// SetY implements interface termui.GridBufferer
func (i *Input) SetY(y int) {
i.Par.SetY(y)
}
// SendMessage send the input text through the SlackService
func (i *Input) SendMessage(svc *SlackService, channel string, message string) {
svc.SendMessage(channel, message)
}
// Insert will insert a given key at the place of the current CursorPosition
func (i *Input) Insert(key rune) {
if len(i.Text) < i.Par.InnerBounds().Dx()-1 {
left := make([]rune, len(i.Text[0:i.CursorPosition]))
copy(left, i.Text[0:i.CursorPosition])
left = append(left, key)
i.Text = append(left, i.Text[i.CursorPosition:]...)
i.Par.Text = string(i.Text)
i.MoveCursorRight()
}
}
// Backspace will remove a character in front of the CursorPosition
func (i *Input) Backspace() {
if i.CursorPosition > 0 {
i.Text = append(i.Text[0:i.CursorPosition-1], i.Text[i.CursorPosition:]...)
i.Par.Text = string(i.Text)
i.MoveCursorLeft()
}
}
// Delete will remove a character at the CursorPosition
func (i *Input) Delete() {
if i.CursorPosition < len(i.Text) {
i.Text = append(i.Text[0:i.CursorPosition], i.Text[i.CursorPosition+1:]...)
i.Par.Text = string(i.Text)
}
}
// MoveCursorRight will increase the current CursorPosition with 1
func (i *Input) MoveCursorRight() {
if i.CursorPosition < len(i.Text) {
i.CursorPosition++
}
}
// MoveCursorLeft will decrease the current CursorPosition with 1
func (i *Input) MoveCursorLeft() {
if i.CursorPosition > 0 {
i.CursorPosition--
}
}
// IsEmpty will return true when the input is empty
func (i *Input) IsEmpty() bool {
if i.Par.Text == "" {
return true
}
return false
}
// Clear will empty the input and move the cursor to the start position
func (i *Input) Clear() {
i.Text = make([]rune, 0)
i.Par.Text = ""
i.CursorPosition = 0
}
// GetText returns the text currently in the input
func (i *Input) GetText() string {
return i.Par.Text
}

104
main.go Normal file
View File

@ -0,0 +1,104 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/user"
"path"
"github.com/gizak/termui"
termbox "github.com/nsf/termbox-go"
)
const (
VERSION = "v0.2.3"
USAGE = `NAME:
slack-term - slack client for your terminal
USAGE:
slack-term -config [path-to-config]
VERSION:
%s
GLOBAL OPTIONS:
--help, -h
`
)
var (
flgConfig string
flgUsage bool
)
func init() {
// Get home dir for config file default
usr, err := user.Current()
if err != nil {
log.Fatal(err)
}
// Parse flags
flag.StringVar(
&flgConfig,
"config",
path.Join(usr.HomeDir, "slack-term.json"),
"location of config file",
)
flag.Usage = func() {
fmt.Printf(USAGE, VERSION)
}
flag.Parse()
}
func main() {
// Start terminal user interface
err := termui.Init()
if err != nil {
log.Fatal(err)
}
defer termui.Close()
// Create custom event stream for termui because
// termui's one has data race conditions with its
// event handling. We're circumventing it here until
// it has been fixed.
customEvtStream := &termui.EvtStream{
Handlers: make(map[string]func(termui.Event)),
}
termui.DefaultEvtStream = customEvtStream
// Create context
ctx, err := CreateAppContext(flgConfig)
if err != nil {
termbox.Close()
log.Println(err)
os.Exit(0)
}
// Setup body
termui.Body.AddRows(
termui.NewRow(
termui.NewCol(ctx.Config.SidebarWidth, 0, ctx.View.Channels),
termui.NewCol(ctx.Config.MainWidth, 0, ctx.View.Chat),
),
termui.NewRow(
termui.NewCol(ctx.Config.SidebarWidth, 0, ctx.View.Mode),
termui.NewCol(ctx.Config.MainWidth, 0, ctx.View.Input),
),
)
termui.Body.Align()
termui.Render(termui.Body)
// Set body in context
ctx.Body = termui.Body
// Register handlers
RegisterEventHandlers(ctx)
termui.Loop()
}

82
mode.go Normal file
View File

@ -0,0 +1,82 @@
package main
import "github.com/gizak/termui"
// Mode is the definition of Mode component
type Mode struct {
Par *termui.Par
}
// CreateMode is the constructor of the Mode struct
func CreateMode() *Mode {
mode := &Mode{
Par: termui.NewPar("NORMAL"),
}
mode.Par.Height = 3
return mode
}
// Buffer implements interface termui.Bufferer
func (m *Mode) Buffer() termui.Buffer {
buf := m.Par.Buffer()
// Center text
space := m.Par.InnerWidth()
word := len(m.Par.Text)
midSpace := space / 2
midWord := word / 2
start := midSpace - midWord
cells := termui.DefaultTxBuilder.Build(
m.Par.Text, m.Par.TextFgColor, m.Par.TextBgColor)
i, j := 0, 0
x := m.Par.InnerBounds().Min.X
for x < m.Par.InnerBounds().Max.X {
if i < start {
buf.Set(
x, m.Par.InnerY(),
termui.Cell{
Ch: ' ',
Fg: m.Par.TextFgColor,
Bg: m.Par.TextBgColor,
},
)
x++
i++
} else {
if j < len(cells) {
buf.Set(x, m.Par.InnerY(), cells[j])
i++
j++
}
x++
}
}
return buf
}
// GetHeight implements interface termui.GridBufferer
func (m *Mode) GetHeight() int {
return m.Par.Block.GetHeight()
}
// SetWidth implements interface termui.GridBufferer
func (m *Mode) SetWidth(w int) {
m.Par.SetWidth(w)
}
// SetX implements interface termui.GridBufferer
func (m *Mode) SetX(x int) {
m.Par.SetX(x)
}
// SetY implements interface termui.GridBufferer
func (m *Mode) SetY(y int) {
m.Par.SetY(y)
}

469
slack.go Normal file
View File

@ -0,0 +1,469 @@
package main
import (
"fmt"
"log"
"regexp"
"strconv"
"strings"
"time"
"github.com/nlopes/slack"
)
const (
ChannelTypeChannel = "channel"
ChannelTypeGroup = "group"
ChannelTypeIM = "im"
)
type SlackService struct {
Client *slack.Client
RTM *slack.RTM
SlackChannels []interface{}
Channels []Channel
UserCache map[string]string
CurrentUserID string
}
type Channel struct {
ID string
Name string
Topic string
Type string
UserID string
}
// NewSlackService is the constructor for the SlackService and will initialize
// the RTM and a Client
func NewSlackService(token string) *SlackService {
svc := &SlackService{
Client: slack.New(token),
UserCache: make(map[string]string),
}
// Get user associated with token, mainly
// used to identify user when new messages
// arrives
authTest, err := svc.Client.AuthTest()
if err != nil {
log.Fatal("ERROR: not able to authorize client, check your connection and/or slack-token")
}
svc.CurrentUserID = authTest.UserID
// Create RTM
svc.RTM = svc.Client.NewRTM()
go svc.RTM.ManageConnection()
// Creation of user cache this speeds up
// the uncovering of usernames of messages
users, _ := svc.Client.GetUsers()
for _, user := range users {
// only add non-deleted users
if !user.Deleted {
svc.UserCache[user.ID] = user.Name
}
}
return svc
}
// GetChannels will retrieve all available channels, groups, and im channels.
// Because the channels are of different types, we will append them to
// an []interface as well as to a []Channel which will give us easy access
// to the id and name of the Channel.
func (s *SlackService) GetChannels() []Channel {
var chans []Channel
// Channel
slackChans, err := s.Client.GetChannels(true)
if err != nil {
chans = append(chans, Channel{})
}
for _, chn := range slackChans {
s.SlackChannels = append(s.SlackChannels, chn)
chans = append(
chans, Channel{
ID: chn.ID,
Name: chn.Name,
Topic: chn.Topic.Value,
Type: ChannelTypeChannel,
UserID: "",
},
)
}
// Groups
slackGroups, err := s.Client.GetGroups(true)
if err != nil {
chans = append(chans, Channel{})
}
for _, grp := range slackGroups {
s.SlackChannels = append(s.SlackChannels, grp)
chans = append(
chans, Channel{
ID: grp.ID,
Name: grp.Name,
Topic: grp.Topic.Value,
Type: ChannelTypeGroup,
UserID: "",
},
)
}
// IM
slackIM, err := s.Client.GetIMChannels()
if err != nil {
chans = append(chans, Channel{})
}
for _, im := range slackIM {
// Uncover name, when we can't uncover name for
// IM channel this is then probably a deleted
// user, because we won't add deleted users
// to the UserCache, so we skip it
name, ok := s.UserCache[im.User]
if ok {
chans = append(
chans,
Channel{
ID: im.ID,
Name: name,
Topic: "",
Type: ChannelTypeIM,
UserID: im.User,
},
)
s.SlackChannels = append(s.SlackChannels, im)
}
}
s.Channels = chans
return chans
}
// GetUserPresence will get the presence of a specific user
func (s *SlackService) GetUserPresence(userID string) (string, error) {
presence, err := s.Client.GetUserPresence(userID)
if err != nil {
return "", err
}
return presence.Presence, nil
}
// SetChannelReadMark will set the read mark for a channel, group, and im
// channel based on the current time.
func (s *SlackService) SetChannelReadMark(channel interface{}) {
switch channel := channel.(type) {
case slack.Channel:
s.Client.SetChannelReadMark(
channel.ID, fmt.Sprintf("%f",
float64(time.Now().Unix())),
)
case slack.Group:
s.Client.SetGroupReadMark(
channel.ID, fmt.Sprintf("%f",
float64(time.Now().Unix())),
)
case slack.IM:
s.Client.MarkIMChannel(
channel.ID, fmt.Sprintf("%f",
float64(time.Now().Unix())),
)
}
}
// SendMessage will send a message to a particular channel
func (s *SlackService) SendMessage(channel string, message string) {
// https://godoc.org/github.com/nlopes/slack#PostMessageParameters
postParams := slack.PostMessageParameters{
AsUser: true,
}
// https://godoc.org/github.com/nlopes/slack#Client.PostMessage
s.Client.PostMessage(channel, message, postParams)
}
// GetMessages will get messages for a channel, group or im channel delimited
// by a count.
func (s *SlackService) GetMessages(channel interface{}, count int) []string {
// https://api.slack.com/methods/channels.history
historyParams := slack.HistoryParameters{
Count: count,
Inclusive: false,
Unreads: false,
}
// https://godoc.org/github.com/nlopes/slack#History
history := new(slack.History)
var err error
switch chnType := channel.(type) {
case slack.Channel:
history, err = s.Client.GetChannelHistory(chnType.ID, historyParams)
if err != nil {
log.Fatal(err) // FIXME
}
case slack.Group:
history, err = s.Client.GetGroupHistory(chnType.ID, historyParams)
if err != nil {
log.Fatal(err) // FIXME
}
case slack.IM:
history, err = s.Client.GetIMHistory(chnType.ID, historyParams)
if err != nil {
log.Fatal(err) // FIXME
}
}
// Construct the messages
var messages []string
for _, message := range history.Messages {
msg := s.CreateMessage(message)
messages = append(messages, msg...)
}
// Reverse the order of the messages, we want the newest in
// the last place
var messagesReversed []string
for i := len(messages) - 1; i >= 0; i-- {
messagesReversed = append(messagesReversed, messages[i])
}
return messagesReversed
}
// CreateMessage will create a string formatted message that can be rendered
// in the Chat pane.
//
// [23:59] <erroneousboat> Hello world!
//
// This returns an array of string because we will try to uncover attachments
// associated with messages.
func (s *SlackService) CreateMessage(message slack.Message) []string {
var msgs []string
var name string
// Get username from cache
name, ok := s.UserCache[message.User]
// Name not in cache
if !ok {
if message.BotID != "" {
// Name not found, perhaps a bot, use Username
name, ok = s.UserCache[message.BotID]
if !ok {
// Not found in cache, add it
name = message.Username
s.UserCache[message.BotID] = message.Username
}
} else {
// Not a bot, not in cache, get user info
user, err := s.Client.GetUserInfo(message.User)
if err != nil {
name = "unknown"
s.UserCache[message.User] = name
} else {
name = user.Name
s.UserCache[message.User] = user.Name
}
}
}
if name == "" {
name = "unknown"
}
// When there are attachments append them
if len(message.Attachments) > 0 {
msgs = append(msgs, createMessageFromAttachments(message.Attachments)...)
}
// Parse time
floatTime, err := strconv.ParseFloat(message.Timestamp, 64)
if err != nil {
floatTime = 0.0
}
intTime := int64(floatTime)
// Format message
msg := fmt.Sprintf(
"[%s] <%s> %s",
time.Unix(intTime, 0).Format("15:04"),
name,
parseMessage(s, message.Text),
)
msgs = append(msgs, msg)
return msgs
}
func (s *SlackService) CreateMessageFromMessageEvent(message *slack.MessageEvent) []string {
var msgs []string
var name string
// Append (edited) when an edited message is received
if message.SubType == "message_changed" {
message = &slack.MessageEvent{Msg: *message.SubMessage}
message.Text = fmt.Sprintf("%s (edited)", message.Text)
}
// Get username from cache
name, ok := s.UserCache[message.User]
// Name not in cache
if !ok {
if message.BotID != "" {
// Name not found, perhaps a bot, use Username
name, ok = s.UserCache[message.BotID]
if !ok {
// Not found in cache, add it
name = message.Username
s.UserCache[message.BotID] = message.Username
}
} else {
// Not a bot, not in cache, get user info
user, err := s.Client.GetUserInfo(message.User)
if err != nil {
name = "unknown"
s.UserCache[message.User] = name
} else {
name = user.Name
s.UserCache[message.User] = user.Name
}
}
}
if name == "" {
name = "unknown"
}
// When there are attachments append them
if len(message.Attachments) > 0 {
msgs = append(msgs, createMessageFromAttachments(message.Attachments)...)
}
// Parse time
floatTime, err := strconv.ParseFloat(message.Timestamp, 64)
if err != nil {
floatTime = 0.0
}
intTime := int64(floatTime)
// Format message
msg := fmt.Sprintf(
"[%s] <%s> %s",
time.Unix(intTime, 0).Format("15:04"),
name,
parseMessage(s, message.Text),
)
msgs = append(msgs, msg)
return msgs
}
// parseMessage will parse a message string and find and replace:
// - emoji's
// - mentions
func parseMessage(s *SlackService, msg string) string {
// NOTE: Commented out because rendering of the emoji's
// creates artifacts from the last view because of
// double width emoji's
// msg = parseEmoji(msg)
msg = parseMentions(s, msg)
return msg
}
// parseMentions will try to find mention placeholders in the message
// string and replace them with the correct username with and @ symbol
//
// Mentions have the following format:
// <@U12345|erroneousboat>
// <@U12345>
func parseMentions(s *SlackService, msg string) string {
r := regexp.MustCompile(`\<@(\w+\|*\w+)\>`)
rs := r.FindStringSubmatch(msg)
if len(rs) < 1 {
return msg
}
return r.ReplaceAllStringFunc(
msg, func(str string) string {
var userID string
split := strings.Split(rs[1], "|")
if len(split) > 0 {
userID = split[0]
} else {
userID = rs[1]
}
name, ok := s.UserCache[userID]
if !ok {
user, err := s.Client.GetUserInfo(userID)
if err != nil {
name = "unknown"
s.UserCache[userID] = name
} else {
name = user.Name
s.UserCache[userID] = user.Name
}
}
if name == "" {
name = "unknown"
}
return "@" + name
},
)
}
// parseEmoji will try to find emoji placeholders in the message
// string and replace them with the correct unicode equivalent
func parseEmoji(msg string) string {
r := regexp.MustCompile("(:\\w+:)")
return r.ReplaceAllStringFunc(
msg, func(str string) string {
code, ok := EmojiCodemap[str]
if !ok {
return str
}
return code
},
)
}
// createMessageFromAttachments will construct a array of string of the Field
// values of Attachments from a Message.
func createMessageFromAttachments(atts []slack.Attachment) []string {
var msgs []string
for _, att := range atts {
for i := len(att.Fields) - 1; i >= 0; i-- {
msgs = append(msgs,
fmt.Sprintf(
"%s %s",
att.Fields[i].Title,
att.Fields[i].Value,
),
)
}
if att.Text != "" {
msgs = append(msgs, att.Text)
}
if att.Title != "" {
msgs = append(msgs, att.Title)
}
}
return msgs
}

45
view_chat.go Normal file
View File

@ -0,0 +1,45 @@
package main
import (
"github.com/gizak/termui"
)
type View struct {
Input *Input
Chat *Chat
Channels *Channels
Mode *Mode
}
func CreateChatView(svc *SlackService) *View {
input := CreateInput()
channels := CreateChannels(svc, input.Par.Height)
chat := CreateChat(
svc,
input.Par.Height,
svc.SlackChannels[channels.SelectedChannel],
svc.Channels[channels.SelectedChannel],
)
mode := CreateMode()
view := &View{
Input: input,
Channels: channels,
Chat: chat,
Mode: mode,
}
return view
}
func (v *View) Refresh() {
termui.Render(
v.Input,
v.Chat,
v.Channels,
v.Mode,
)
}