Merge branch 'threads'

* threads:
  Update comment
  Update threads reply functionality
  Add reply to threads functionality
  Fix thread timestamp checking
  Fix pagination problem
  Update handling thread replies in event handler
  Update message event
  Implement new Message setup
  Start with thread support

Reference #91
This commit is contained in:
erroneousboat 2019-03-16 12:29:42 +01:00
commit 112524daad
6 changed files with 435 additions and 215 deletions

View File

@ -12,58 +12,19 @@ import (
"github.com/erroneousboat/slack-term/config"
)
var (
COLORS = []string{
"fg-black",
"fg-red",
"fg-green",
"fg-yellow",
"fg-blue",
"fg-magenta",
"fg-cyan",
"fg-white",
}
)
type Message struct {
Time time.Time
Name string
Content string
StyleTime string
StyleName string
StyleText string
FormatTime string
}
func (m Message) colorizeName(styleName string) string {
if strings.Contains(styleName, "colorize") {
var sum int
for _, c := range m.Name {
sum = sum + int(c)
}
i := sum % len(COLORS)
return strings.Replace(m.StyleName, "colorize", COLORS[i], -1)
}
return styleName
}
// Chat is the definition of a Chat component
type Chat struct {
List *termui.List
Messages []Message
Messages map[string]Message
Offset int
}
// CreateChat is the constructor for the Chat struct
func CreateChatComponent(inputHeight int) *Chat {
chat := &Chat{
List: termui.NewList(),
Offset: 0,
List: termui.NewList(),
Messages: make(map[string]Message),
Offset: 0,
}
chat.List.Height = termui.TermHeight() - inputHeight
@ -74,59 +35,8 @@ func CreateChatComponent(inputHeight int) *Chat {
// Buffer implements interface termui.Bufferer
func (c *Chat) Buffer() termui.Buffer {
// Build cells. We're building parts of the message individually, or else
// DefaultTxBuilder will interpret potential markdown usage in a message
// as well.
cells := make([]termui.Cell, 0)
for i, msg := range c.Messages {
// When msg.Time and msg.Name are empty (in the case of attachments)
// don't add the time and name parts.
if (msg.Time != time.Time{} && msg.Name != "") {
// Time
cells = append(cells, termui.DefaultTxBuilder.Build(
fmt.Sprintf(
"[[%s]](%s) ",
msg.Time.Format(msg.FormatTime),
msg.StyleTime,
),
termui.ColorDefault, termui.ColorDefault)...,
)
// Name
cells = append(cells, termui.DefaultTxBuilder.Build(
fmt.Sprintf("[<%s>](%s) ",
msg.Name,
msg.colorizeName(msg.StyleName),
),
termui.ColorDefault, termui.ColorDefault)...,
)
}
// Hack, in order to get the correct fg and bg attributes. This is
// because the readAttr function in termui is unexported.
txCells := termui.DefaultTxBuilder.Build(
fmt.Sprintf("[.](%s)", msg.StyleText),
termui.ColorDefault, termui.ColorDefault,
)
// Text
for _, r := range msg.Content {
cells = append(
cells,
termui.Cell{
Ch: r,
Fg: txCells[0].Fg,
Bg: txCells[0].Bg,
},
)
}
// Add a newline after every message
if i < len(c.Messages)-1 {
cells = append(cells, termui.Cell{Ch: '\n'})
}
}
// Convert Messages into termui.Cell
cells := c.MessagesToCells(c.Messages)
// We will create an array of Line structs, this allows us
// to more easily render the items in a list. We will range
@ -262,17 +172,26 @@ func (c *Chat) SetMessages(messages []Message) {
// Reset offset first, when scrolling in view and changing channels we
// want the offset to be 0 when loading new messages
c.Offset = 0
c.Messages = messages
for _, msg := range messages {
c.Messages[msg.ID] = msg
}
}
// AddMessage adds a single message to Messages
func (c *Chat) AddMessage(message Message) {
c.Messages = append(c.Messages, message)
c.Messages[message.ID] = message
}
// AddReply adds a single reply to a parent thread, it also sets
// the thread separator
func (c *Chat) AddReply(parentID string, message Message) {
message.Thread = " "
c.Messages[parentID].Messages[message.ID] = message
}
// ClearMessages clear the c.Messages
func (c *Chat) ClearMessages() {
c.Messages = make([]Message, 0)
c.Messages = make(map[string]Message)
}
// ScrollUp will render the chat messages based on the Offset of the Chat
@ -312,6 +231,80 @@ func (c *Chat) SetBorderLabel(channelName string) {
c.List.BorderLabel = channelName
}
// MessagesToCells is a wrapper around MessageToCells to use for a slice of
// of type Message
func (c *Chat) MessagesToCells(msgs map[string]Message) []termui.Cell {
cells := make([]termui.Cell, 0)
sortedMessages := SortMessages(msgs)
for i, msg := range sortedMessages {
cells = append(cells, c.MessageToCells(msg)...)
if len(msg.Messages) > 0 {
cells = append(cells, termui.Cell{Ch: '\n'})
cells = append(cells, c.MessagesToCells(msg.Messages)...)
}
// Add a newline after every message
if i < len(sortedMessages)-1 {
cells = append(cells, termui.Cell{Ch: '\n'})
}
}
return cells
}
// MessageToCells will convert a Message struct to termui.Cell
//
// We're building parts of the message individually, or else DefaultTxBuilder
// will interpret potential markdown usage in a message as well.
func (c *Chat) MessageToCells(msg Message) []termui.Cell {
cells := make([]termui.Cell, 0)
// When msg.Time and msg.Name are empty (in the case of attachments)
// don't add the time and name parts.
if (msg.Time != time.Time{} && msg.Name != "") {
// Time
cells = append(cells, termui.DefaultTxBuilder.Build(
msg.GetTime(),
termui.ColorDefault, termui.ColorDefault)...,
)
// Thread
cells = append(cells, termui.DefaultTxBuilder.Build(
msg.GetThread(),
termui.ColorDefault, termui.ColorDefault)...,
)
// Name
cells = append(cells, termui.DefaultTxBuilder.Build(
msg.GetName(),
termui.ColorDefault, termui.ColorDefault)...,
)
}
// Hack, in order to get the correct fg and bg attributes. This is
// because the readAttr function in termui is unexported.
txCells := termui.DefaultTxBuilder.Build(
msg.GetContent(),
termui.ColorDefault, termui.ColorDefault,
)
// Text
for _, r := range msg.Content {
cells = append(
cells,
termui.Cell{
Ch: r,
Fg: txCells[0].Fg,
Bg: txCells[0].Bg,
},
)
}
return cells
}
// Help shows the usage and key bindings in the chat pane
func (c *Chat) Help(usage string, cfg *config.Config) {
help := []Message{
@ -348,5 +341,7 @@ func (c *Chat) Help(usage string, cfg *config.Config) {
help = append(help, Message{Content: ""})
}
c.Messages = help
for _, msg := range help {
c.Messages[msg.ID] = msg
}
}

95
components/message.go Normal file
View File

@ -0,0 +1,95 @@
package components
import (
"fmt"
"sort"
"strings"
"time"
)
var (
COLORS = []string{
"fg-black",
"fg-red",
"fg-green",
"fg-yellow",
"fg-blue",
"fg-magenta",
"fg-cyan",
"fg-white",
}
)
type Message struct {
ID string
Messages map[string]Message
Time time.Time
Thread string
Name string
Content string
StyleTime string
StyleThread string
StyleName string
StyleText string
FormatTime string
}
func (m Message) GetTime() string {
return fmt.Sprintf(
"[[%s]](%s) ",
m.Time.Format(m.FormatTime),
m.StyleTime,
)
}
func (m Message) GetThread() string {
return fmt.Sprintf("[%s](%s)",
m.Thread,
m.StyleThread,
)
}
func (m Message) GetName() string {
return fmt.Sprintf("[<%s>](%s) ",
m.Name,
m.colorizeName(m.StyleName),
)
}
func (m Message) GetContent() string {
return fmt.Sprintf("[.](%s)", m.StyleText)
}
func (m Message) colorizeName(styleName string) string {
if strings.Contains(styleName, "colorize") {
var sum int
for _, c := range m.Name {
sum = sum + int(c)
}
i := sum % len(COLORS)
return strings.Replace(m.StyleName, "colorize", COLORS[i], -1)
}
return styleName
}
func SortMessages(msgs map[string]Message) []Message {
keys := make([]string, 0)
for k := range msgs {
keys = append(keys, k)
}
sort.Strings(keys)
sortedMessages := make([]Message, 0)
for _, k := range keys {
sortedMessages = append(sortedMessages, msgs[k])
}
return sortedMessages
}

View File

@ -128,6 +128,7 @@ func getDefaultConfig() Config {
Message: Message{
Time: "",
TimeFormat: "15:04",
Thread: "fg-bold",
Name: "",
Text: "",
},

View File

@ -18,6 +18,7 @@ type View struct {
type Message struct {
Time string `json:"time"`
Name string `json:"name"`
Thread string `json:"thread"`
Text string `json:"text"`
TimeFormat string `json:"time_format"`
}

View File

@ -113,7 +113,7 @@ func messageHandler(ctx *context.AppContext) {
case *slack.MessageEvent:
// Construct message
msg, err := ctx.Service.CreateMessageFromMessageEvent(ev)
msg, err := ctx.Service.CreateMessageFromMessageEvent(ev, ev.Channel)
if err != nil {
continue
}
@ -121,12 +121,12 @@ func messageHandler(ctx *context.AppContext) {
// Add message to the selected channel
if ev.Channel == ctx.View.Channels.ChannelItems[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],
)
// When timestamp isn't set this is a thread reply,
// handle as such
if ev.ThreadTimestamp != "" {
ctx.View.Chat.AddReply(ev.ThreadTimestamp, msg)
} else {
ctx.View.Chat.AddMessage(msg)
}
termui.Render(ctx.View.Chat)
@ -243,8 +243,8 @@ func actionSend(ctx *context.AppContext) {
ctx.View.Input.Clear()
ctx.View.Refresh()
// Send message
err := ctx.Service.SendMessage(
// Send slash command
isCmd, err := ctx.Service.SendCommand(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
message,
)
@ -254,6 +254,19 @@ func actionSend(ctx *context.AppContext) {
)
}
// Send message
if !isCmd {
err := ctx.Service.SendMessage(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
message,
)
if err != nil {
ctx.View.Debug.Println(
err.Error(),
)
}
}
// Clear notification icon if there is any
channelItem := ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel]
if channelItem.Notification {

View File

@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"log"
"regexp"
"sort"
"strconv"
@ -22,6 +23,7 @@ type SlackService struct {
RTM *slack.RTM
Conversations []slack.Channel
UserCache map[string]string
ThreadCache map[string]string
CurrentUserID string
CurrentUsername string
}
@ -30,9 +32,10 @@ type SlackService struct {
// the RTM and a Client
func NewSlackService(config *config.Config) (*SlackService, error) {
svc := &SlackService{
Config: config,
Client: slack.New(config.SlackToken),
UserCache: make(map[string]string),
Config: config,
Client: slack.New(config.SlackToken),
UserCache: make(map[string]string),
ThreadCache: make(map[string]string),
}
// Get user associated with token, mainly
@ -297,6 +300,66 @@ func (s *SlackService) SendMessage(channelID string, message string) error {
return nil
}
// SendReply will send a message to a particular thread, specifying the
// ThreadTimestamp will make it reply to that specific thread. (see:
// https://api.slack.com/docs/message-threading, 'Posting replies')
func (s *SlackService) SendReply(channelID string, threadID string, message string) error {
// https://godoc.org/github.com/nlopes/slack#PostMessageParameters
postParams := slack.PostMessageParameters{
AsUser: true,
Username: s.CurrentUsername,
LinkNames: 1,
ThreadTimestamp: threadID,
}
// https://godoc.org/github.com/nlopes/slack#Client.PostMessage
_, _, err := s.Client.PostMessage(channelID, message, postParams)
if err != nil {
return err
}
return nil
}
// SendCommand will send a specific command to slack. First we check
// wether we are dealing with a command, and if it is one of the supported
// ones.
func (s *SlackService) SendCommand(channelID string, message string) (bool, error) {
// First check if it begins with slash and a command
r, err := regexp.Compile(`^/\w+`)
if err != nil {
return false, err
}
match := r.MatchString(message)
if !match {
return false, nil
}
// Execute the the command when supported
switch r.FindString(message) {
case "/thread":
r := regexp.MustCompile(`(?P<cmd>^/\w+) (?P<id>\w+) (?P<msg>.*)`)
subMatch := r.FindStringSubmatch(message)
if len(subMatch) < 4 {
return false, errors.New("'/thread' command malformed")
}
threadID := s.ThreadCache[subMatch[2]]
msg := subMatch[3]
err := s.SendReply(channelID, threadID, msg)
if err != nil {
return false, err
}
return true, nil
}
return false, nil
}
// GetMessages will get messages for a channel, group or im channel delimited
// by a count.
func (s *SlackService) GetMessages(channelID string, count int) ([]components.Message, error) {
@ -316,8 +379,8 @@ func (s *SlackService) GetMessages(channelID string, count int) ([]components.Me
// Construct the messages
var messages []components.Message
for _, message := range history.Messages {
msg := s.CreateMessage(message)
messages = append(messages, msg...)
msg := s.CreateMessage(message, channelID)
messages = append(messages, msg)
}
// Reverse the order of the messages, we want the newest in
@ -334,11 +397,7 @@ func (s *SlackService) GetMessages(channelID string, count int) ([]components.Me
// 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) []components.Message {
var msgs []components.Message
func (s *SlackService) CreateMessage(message slack.Message, channelID string) components.Message {
var name string
// Get username from cache
@ -371,11 +430,6 @@ func (s *SlackService) CreateMessage(message slack.Message) []components.Message
name = "unknown"
}
// When there are attachments append them
if len(message.Attachments) > 0 {
msgs = append(msgs, s.CreateMessageFromAttachments(message.Attachments)...)
}
// Parse time
floatTime, err := strconv.ParseFloat(message.Timestamp, 64)
if err != nil {
@ -385,91 +439,137 @@ func (s *SlackService) CreateMessage(message slack.Message) []components.Message
// Format message
msg := components.Message{
Time: time.Unix(intTime, 0),
Name: name,
Content: parseMessage(s, message.Text),
StyleTime: s.Config.Theme.Message.Time,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
ID: message.Timestamp,
Messages: make(map[string]components.Message),
Time: time.Unix(intTime, 0),
Name: name,
Content: parseMessage(s, message.Text),
StyleTime: s.Config.Theme.Message.Time,
StyleThread: s.Config.Theme.Message.Thread,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
}
msgs = append(msgs, msg)
// When there are attachments, add them to Messages
//
// NOTE: attachments don't have an id or a timestamp that we can
// use as a key value for the Messages field, so we use the index
// of the returned array.
if len(message.Attachments) > 0 {
atts := s.CreateMessageFromAttachments(message.Attachments)
return msgs
for i, a := range atts {
msg.Messages[strconv.Itoa(i)] = a
}
}
// When the message timestamp and thread timestamp are the same, we
// have a parent message. This means it contains a thread with replies.
//
// Additionally, we set the thread timestamp in the s.ThreadCache with
// the base62 representation of the timestamp. We do this because
// we if we want to reply to a thread, we need to reference this
// timestamp. Which is too long to type, we shorten it and remember the
// reference in the cache.
if message.ThreadTimestamp != "" && message.ThreadTimestamp == message.Timestamp {
// Set the thread identifier for thread cache
f, _ := strconv.ParseFloat(message.ThreadTimestamp, 64)
threadID := hashID(int(f))
s.ThreadCache[threadID] = message.ThreadTimestamp
// Set thread prefix for message
msg.Thread = fmt.Sprintf("%s ", threadID)
// Create the message replies from the thread
replies := s.CreateMessageFromReplies(message, channelID)
for _, reply := range replies {
msg.Messages[reply.ID] = reply
}
}
return msg
}
func (s *SlackService) CreateMessageFromMessageEvent(message *slack.MessageEvent) ([]components.Message, error) {
// CreateMessageFromReplies will create components.Message struct from
// the conversation replies from slack.
//
// Useful documentation:
//
// https://api.slack.com/docs/message-threading
// https://api.slack.com/methods/conversations.replies
// https://godoc.org/github.com/nlopes/slack#Client.GetConversationReplies
// https://godoc.org/github.com/nlopes/slack#GetConversationRepliesParameters
func (s *SlackService) CreateMessageFromReplies(message slack.Message, channelID string) []components.Message {
msgs := make([]slack.Message, 0)
var msgs []components.Message
var name string
initReplies, _, initCur, err := s.Client.GetConversationReplies(
&slack.GetConversationRepliesParameters{
ChannelID: channelID,
Timestamp: message.ThreadTimestamp,
Limit: 200,
},
)
if err != nil {
log.Fatal(err) // FIXME
}
msgs = append(msgs, initReplies...)
nextCur := initCur
for nextCur != "" {
conversationReplies, _, cursor, err := s.Client.GetConversationReplies(&slack.GetConversationRepliesParameters{
ChannelID: channelID,
Timestamp: message.ThreadTimestamp,
Cursor: nextCur,
Limit: 200,
})
if err != nil {
log.Fatal(err) // FIXME
}
msgs = append(msgs, conversationReplies...)
nextCur = cursor
}
var replies []components.Message
for _, reply := range msgs {
// Because the conversations api returns an entire thread (a
// message plus all the messages in reply), we need to check if
// one of the replies isn't the parent that we started with.
//
// Keep in mind that the api returns the replies with the latest
// as the first element.
if reply.ThreadTimestamp != "" && reply.ThreadTimestamp == reply.Timestamp {
continue
}
msg := s.CreateMessage(reply, channelID)
// Set the thread separator
msg.Thread = " "
replies = append(replies, msg)
}
return replies
}
func (s *SlackService) CreateMessageFromMessageEvent(message *slack.MessageEvent, channelID string) (components.Message, error) {
msg := slack.Message{Msg: message.Msg}
switch message.SubType {
case "message_changed":
// Append (edited) when an edited message is received
message = &slack.MessageEvent{Msg: *message.SubMessage}
message.Text = fmt.Sprintf("%s (edited)", message.Text)
msg = slack.Message{Msg: *message.SubMessage}
msg.Text = fmt.Sprintf("%s (edited)", msg.Text)
case "message_replied":
// Ignore reply events
return nil, errors.New("ignoring reply events")
return components.Message{}, errors.New("ignoring reply events")
}
// 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, s.CreateMessageFromAttachments(message.Attachments)...)
}
// Parse time
floatTime, err := strconv.ParseFloat(message.Timestamp, 64)
if err != nil {
floatTime = 0.0
}
intTime := int64(floatTime)
// Format message
msg := components.Message{
Time: time.Unix(intTime, 0),
Name: name,
Content: parseMessage(s, message.Text),
StyleTime: s.Config.Theme.Message.Time,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
}
msgs = append(msgs, msg)
return msgs, nil
return s.CreateMessage(msg, channelID), nil
}
// parseMessage will parse a message string and find and replace:
@ -546,22 +646,23 @@ func parseEmoji(msg string) string {
)
}
// CreateMessageFromAttachments will construct a array of string of the Field
// values of Attachments from a Message.
// CreateMessageFromAttachments will construct an array of strings from the
// Field values of Attachments of a Message.
func (s *SlackService) CreateMessageFromAttachments(atts []slack.Attachment) []components.Message {
var msgs []components.Message
for _, att := range atts {
for i := len(att.Fields) - 1; i >= 0; i-- {
for _, field := range att.Fields {
msgs = append(msgs, components.Message{
Content: fmt.Sprintf(
"%s %s",
att.Fields[i].Title,
att.Fields[i].Value,
field.Title,
field.Value,
),
StyleTime: s.Config.Theme.Message.Time,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
StyleTime: s.Config.Theme.Message.Time,
StyleThread: s.Config.Theme.Message.Thread,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
},
)
}
@ -570,11 +671,12 @@ func (s *SlackService) CreateMessageFromAttachments(atts []slack.Attachment) []c
msgs = append(
msgs,
components.Message{
Content: fmt.Sprintf("%s", att.Text),
StyleTime: s.Config.Theme.Message.Time,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
Content: fmt.Sprintf("%s", att.Text),
StyleTime: s.Config.Theme.Message.Time,
StyleThread: s.Config.Theme.Message.Thread,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
},
)
}
@ -583,11 +685,12 @@ func (s *SlackService) CreateMessageFromAttachments(atts []slack.Attachment) []c
msgs = append(
msgs,
components.Message{
Content: fmt.Sprintf("%s", att.Title),
StyleTime: s.Config.Theme.Message.Time,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
Content: fmt.Sprintf("%s", att.Title),
StyleTime: s.Config.Theme.Message.Time,
StyleThread: s.Config.Theme.Message.Thread,
StyleName: s.Config.Theme.Message.Name,
StyleText: s.Config.Theme.Message.Text,
FormatTime: s.Config.Theme.Message.TimeFormat,
},
)
}
@ -607,3 +710,15 @@ func (s *SlackService) createChannelItem(chn slack.Channel) components.ChannelIt
StyleText: s.Config.Theme.Channel.Text,
}
}
func hashID(input int) string {
const base62Alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
hash := ""
for input > 0 {
hash = string(base62Alphabet[input%62]) + hash
input = int(input / 62)
}
return hash
}