Initial Commit
This commit is contained in:
parent
65c6adcb0e
commit
d934b91188
2
.gitignore
vendored
2
.gitignore
vendored
@ -24,3 +24,5 @@ _testmain.go
|
|||||||
*.test
|
*.test
|
||||||
*.prof
|
*.prof
|
||||||
|
|
||||||
|
# The binary
|
||||||
|
sluice
|
||||||
|
21
LICENSE
Normal file
21
LICENSE
Normal 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
139
README.md
@ -1,3 +1,140 @@
|
|||||||
# sluice
|
# 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
352
channels.go
Normal 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
250
chat.go
Normal 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
99
config.go
Normal 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
45
context.go
Normal 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
|
||||||
|
}
|
400
event.go
Normal file
400
event.go
Normal 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
135
input.go
Normal 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
104
main.go
Normal 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
82
mode.go
Normal 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
469
slack.go
Normal 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
45
view_chat.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user