erroneousboat 112524daad 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
2019-03-16 12:29:42 +01:00

725 lines
18 KiB
Go

package service
import (
"errors"
"fmt"
"log"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/nlopes/slack"
"github.com/erroneousboat/slack-term/components"
"github.com/erroneousboat/slack-term/config"
)
type SlackService struct {
Config *config.Config
Client *slack.Client
RTM *slack.RTM
Conversations []slack.Channel
UserCache map[string]string
ThreadCache map[string]string
CurrentUserID string
CurrentUsername string
}
// NewSlackService is the constructor for the SlackService and will initialize
// 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),
ThreadCache: 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 {
return nil, errors.New("not able to authorize client, check your connection and if your slack-token is set correctly")
}
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
}
}
// Get name of current user, and set presence to active
currentUser, err := svc.Client.GetUserInfo(svc.CurrentUserID)
if err != nil {
svc.CurrentUsername = "slack-term"
}
svc.CurrentUsername = currentUser.Name
svc.SetUserAsActive()
return svc, nil
}
func (s *SlackService) GetChannels() ([]components.ChannelItem, error) {
slackChans := make([]slack.Channel, 0)
// Initial request
initChans, initCur, err := s.Client.GetConversations(
&slack.GetConversationsParameters{
ExcludeArchived: "true",
Limit: 1000,
Types: []string{
"public_channel",
"private_channel",
"im",
"mpim",
},
},
)
if err != nil {
return nil, err
}
slackChans = append(slackChans, initChans...)
// Paginate over additional channels
nextCur := initCur
for nextCur != "" {
channels, cursor, err := s.Client.GetConversations(
&slack.GetConversationsParameters{
Cursor: nextCur,
ExcludeArchived: "true",
Limit: 1000,
Types: []string{
"public_channel",
"private_channel",
"im",
"mpim",
},
},
)
if err != nil {
return nil, err
}
slackChans = append(slackChans, channels...)
nextCur = cursor
}
// We're creating tempChan, because we want to be able to
// sort the types of channels into buckets
type tempChan struct {
channelItem components.ChannelItem
slackChannel slack.Channel
}
// Initialize buckets
buckets := make(map[int]map[string]*tempChan)
buckets[0] = make(map[string]*tempChan) // Channels
buckets[1] = make(map[string]*tempChan) // Group
buckets[2] = make(map[string]*tempChan) // MpIM
buckets[3] = make(map[string]*tempChan) // IM
var wg sync.WaitGroup
for _, chn := range slackChans {
chanItem := s.createChannelItem(chn)
if chn.IsChannel {
if !chn.IsMember {
continue
}
chanItem.Type = components.ChannelTypeChannel
if chn.UnreadCount > 0 {
chanItem.Notification = true
}
buckets[0][chn.ID] = &tempChan{
channelItem: chanItem,
slackChannel: chn,
}
}
if chn.IsGroup {
if !chn.IsMember {
continue
}
// This is done because MpIM channels are also considered groups
if chn.IsMpIM {
if !chn.IsOpen {
continue
}
chanItem.Type = components.ChannelTypeMpIM
if chn.UnreadCount > 0 {
chanItem.Notification = true
}
buckets[2][chn.ID] = &tempChan{
channelItem: chanItem,
slackChannel: chn,
}
} else {
chanItem.Type = components.ChannelTypeGroup
if chn.UnreadCount > 0 {
chanItem.Notification = true
}
buckets[1][chn.ID] = &tempChan{
channelItem: chanItem,
slackChannel: chn,
}
}
}
if chn.IsIM {
// Check if user is deleted, we do this by checking the user id,
// and see if we have the user in the UserCache
name, ok := s.UserCache[chn.User]
if !ok {
continue
}
chanItem.Name = name
chanItem.Type = components.ChannelTypeIM
if chn.UnreadCount > 0 {
chanItem.Notification = true
}
buckets[3][chn.User] = &tempChan{
channelItem: chanItem,
slackChannel: chn,
}
wg.Add(1)
go func(user string, buckets map[int]map[string]*tempChan) {
defer wg.Done()
presence, err := s.GetUserPresence(user)
if err != nil {
buckets[3][user].channelItem.Presence = "away"
return
}
buckets[3][user].channelItem.Presence = presence
}(chn.User, buckets)
}
}
wg.Wait()
// Sort the buckets
var keys []int
for k := range buckets {
keys = append(keys, k)
}
sort.Ints(keys)
var chans []components.ChannelItem
for _, k := range keys {
bucket := buckets[k]
// Sort channels in every bucket
tcArr := make([]tempChan, 0)
for _, v := range bucket {
tcArr = append(tcArr, *v)
}
sort.Slice(tcArr, func(i, j int) bool {
return tcArr[i].channelItem.Name < tcArr[j].channelItem.Name
})
// Add ChannelItem and SlackChannel to the SlackService struct
for _, tc := range tcArr {
chans = append(chans, tc.channelItem)
s.Conversations = append(s.Conversations, tc.slackChannel)
}
}
return chans, nil
}
// 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
}
// Set current user presence to active
func (s *SlackService) SetUserAsActive() {
s.Client.SetUserPresence("auto")
}
// MarkAsRead will set the channel as read
func (s *SlackService) MarkAsRead(channelID string) {
s.Client.SetChannelReadMark(
channelID, fmt.Sprintf("%f",
float64(time.Now().Unix())),
)
}
// SendMessage will send a message to a particular channel
func (s *SlackService) SendMessage(channelID string, message string) error {
// https://godoc.org/github.com/nlopes/slack#PostMessageParameters
postParams := slack.PostMessageParameters{
AsUser: true,
Username: s.CurrentUsername,
LinkNames: 1,
}
// https://godoc.org/github.com/nlopes/slack#Client.PostMessage
_, _, err := s.Client.PostMessage(channelID, message, postParams)
if err != nil {
return err
}
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) {
// https://godoc.org/github.com/nlopes/slack#GetConversationHistoryParameters
historyParams := slack.GetConversationHistoryParameters{
ChannelID: channelID,
Limit: count,
Inclusive: false,
}
history, err := s.Client.GetConversationHistory(&historyParams)
if err != nil {
return nil, err
}
// Construct the messages
var messages []components.Message
for _, message := range history.Messages {
msg := s.CreateMessage(message, channelID)
messages = append(messages, msg)
}
// Reverse the order of the messages, we want the newest in
// the last place
var messagesReversed []components.Message
for i := len(messages) - 1; i >= 0; i-- {
messagesReversed = append(messagesReversed, messages[i])
}
return messagesReversed, nil
}
// CreateMessage will create a string formatted message that can be rendered
// in the Chat pane.
//
// [23:59] <erroneousboat> Hello world!
func (s *SlackService) CreateMessage(message slack.Message, channelID string) components.Message {
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"
}
// Parse time
floatTime, err := strconv.ParseFloat(message.Timestamp, 64)
if err != nil {
floatTime = 0.0
}
intTime := int64(floatTime)
// Format message
msg := components.Message{
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,
}
// 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)
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
}
// 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)
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
msg = slack.Message{Msg: *message.SubMessage}
msg.Text = fmt.Sprintf("%s (edited)", msg.Text)
case "message_replied":
return components.Message{}, errors.New("ignoring reply events")
}
return s.CreateMessage(msg, channelID), nil
}
// parseMessage will parse a message string and find and replace:
// - emoji's
// - mentions
func parseMessage(s *SlackService, msg string) string {
if s.Config.Emoji {
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+)\>`)
return r.ReplaceAllStringFunc(
msg, func(str string) string {
rs := r.FindStringSubmatch(str)
if len(rs) < 1 {
return str
}
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 := config.EmojiCodemap[str]
if !ok {
return str
}
return code
},
)
}
// 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 _, field := range att.Fields {
msgs = append(msgs, components.Message{
Content: fmt.Sprintf(
"%s %s",
field.Title,
field.Value,
),
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,
},
)
}
if att.Text != "" {
msgs = append(
msgs,
components.Message{
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,
},
)
}
if att.Title != "" {
msgs = append(
msgs,
components.Message{
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,
},
)
}
}
return msgs
}
func (s *SlackService) createChannelItem(chn slack.Channel) components.ChannelItem {
return components.ChannelItem{
ID: chn.ID,
Name: chn.Name,
Topic: chn.Topic.Value,
UserID: chn.User,
StylePrefix: s.Config.Theme.Channel.Prefix,
StyleIcon: s.Config.Theme.Channel.Icon,
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
}