// © 2016 the CatBase Authors under the WTFPL license. See AUTHORS for the list of authors. // Package slack connects to slack service package slack import ( "encoding/json" "errors" "fmt" "html" "io" "io/ioutil" "net/http" "net/url" "regexp" "strconv" "strings" // "sync/atomic" "context" "time" "github.com/rs/zerolog/log" "github.com/velour/catbase/bot" "github.com/velour/catbase/bot/msg" "github.com/velour/catbase/bot/user" "github.com/velour/catbase/config" "github.com/velour/chat/websocket" ) type Slack struct { config *config.Config url string id string token string ws *websocket.Conn lastRecieved time.Time users map[string]string myBotID string emoji map[string]string event bot.Callback } var idCounter uint64 type slackUserInfoResp struct { Ok bool `json:"ok"` User struct { ID string `json:"id"` Name string `json:"name"` } `json:"user"` } type slackChannelListItem struct { ID string `json:"id"` Name string `json:"name"` IsChannel bool `json:"is_channel"` Created int `json:"created"` Creator string `json:"creator"` IsArchived bool `json:"is_archived"` IsGeneral bool `json:"is_general"` NameNormalized string `json:"name_normalized"` IsShared bool `json:"is_shared"` IsOrgShared bool `json:"is_org_shared"` IsMember bool `json:"is_member"` Members []string `json:"members"` Topic struct { Value string `json:"value"` Creator string `json:"creator"` LastSet int `json:"last_set"` } `json:"topic"` Purpose struct { Value string `json:"value"` Creator string `json:"creator"` LastSet int `json:"last_set"` } `json:"purpose"` PreviousNames []interface{} `json:"previous_names"` NumMembers int `json:"num_members"` } type slackChannelListResp struct { Ok bool `json:"ok"` Channels []slackChannelListItem `json:"channels"` } type slackChannelInfoResp struct { Ok bool `json:"ok"` Channel struct { ID string `json:"id"` Name string `json:"name"` IsChannel bool `json:"is_channel"` Created int `json:"created"` Creator string `json:"creator"` IsArchived bool `json:"is_archived"` IsGeneral bool `json:"is_general"` NameNormalized string `json:"name_normalized"` IsReadOnly bool `json:"is_read_only"` IsShared bool `json:"is_shared"` IsOrgShared bool `json:"is_org_shared"` IsMember bool `json:"is_member"` LastRead string `json:"last_read"` Latest struct { Type string `json:"type"` User string `json:"user"` Text string `json:"text"` Ts string `json:"ts"` } `json:"latest"` UnreadCount int `json:"unread_count"` UnreadCountDisplay int `json:"unread_count_display"` Members []string `json:"members"` Topic struct { Value string `json:"value"` Creator string `json:"creator"` LastSet int64 `json:"last_set"` } `json:"topic"` Purpose struct { Value string `json:"value"` Creator string `json:"creator"` LastSet int `json:"last_set"` } `json:"purpose"` PreviousNames []string `json:"previous_names"` } `json:"channel"` } type slackMessage struct { ID uint64 `json:"id"` Type string `json:"type"` SubType string `json:"subtype"` Hidden bool `json:"hidden"` Channel string `json:"channel"` Text string `json:"text"` User string `json:"user"` Username string `json:"username"` BotID string `json:"bot_id"` Ts string `json:"ts"` ThreadTs string `json:"thread_ts"` Error struct { Code uint64 `json:"code"` Msg string `json:"msg"` } `json:"error"` } type slackReaction struct { Reaction string `json:"name"` Channel string `json:"channel"` Timestamp float64 `json:"timestamp"` } type rtmStart struct { Ok bool `json:"ok"` Error string `json:"error"` URL string `json:"url"` Self struct { ID string `json:"id"` } `json:"self"` } func New(c *config.Config) *Slack { token := c.Get("slack.token", "NONE") if token == "NONE" { log.Fatal().Msgf("No slack token found. Set SLACKTOKEN env.") } return &Slack{ config: c, token: c.Get("slack.token", ""), lastRecieved: time.Now(), users: make(map[string]string), emoji: make(map[string]string), } } func (s *Slack) Send(kind bot.Kind, args ...interface{}) (string, error) { switch kind { case bot.Message: return s.sendMessage(args[0].(string), args[1].(string)) case bot.Action: return s.sendAction(args[0].(string), args[1].(string)) case bot.Edit: return s.edit(args[0].(string), args[1].(string), args[2].(string)) case bot.Reply: switch args[2].(type) { case msg.Message: return s.replyToMessage(args[0].(string), args[1].(string), args[2].(msg.Message)) case string: return s.replyToMessageIdentifier(args[0].(string), args[1].(string), args[2].(string)) default: return "", fmt.Errorf("Invalid types given to Reply") } case bot.Reaction: return s.react(args[0].(string), args[1].(string), args[2].(msg.Message)) default: } return "", fmt.Errorf("No handler for message type %d", kind) } func checkReturnStatus(response *http.Response) error { type Response struct { OK bool `json:"ok"` } body, err := ioutil.ReadAll(response.Body) response.Body.Close() if err != nil { err := fmt.Errorf("Error reading Slack API body: %s", err) return err } var resp Response err = json.Unmarshal(body, &resp) if err != nil { err := fmt.Errorf("Error parsing message response: %s", err) return err } return nil } func (s *Slack) RegisterEvent(f bot.Callback) { s.event = f } func (s *Slack) sendMessageType(channel, message string, meMessage bool) (string, error) { postUrl := "https://slack.com/api/chat.postMessage" if meMessage { postUrl = "https://slack.com/api/chat.meMessage" } nick := s.config.Get("Nick", "bot") icon := s.config.Get("IconURL", "https://placekitten.com/128/128") resp, err := http.PostForm(postUrl, url.Values{"token": {s.token}, "username": {nick}, "icon_url": {icon}, "channel": {channel}, "text": {message}, }) if err != nil { log.Error().Err(err).Msgf("Error sending Slack message") } body, err := ioutil.ReadAll(resp.Body) resp.Body.Close() if err != nil { log.Fatal().Err(err).Msgf("Error reading Slack API body") } log.Debug().Msgf("%+v", body) type MessageResponse struct { OK bool `json:"ok"` Timestamp string `json:"ts"` Message struct { BotID string `json:"bot_id"` } `json:"message"` } var mr MessageResponse err = json.Unmarshal(body, &mr) if err != nil { log.Fatal().Err(err).Msgf("Error parsing message response") } if !mr.OK { return "", errors.New("failure response received") } s.myBotID = mr.Message.BotID return mr.Timestamp, err } func (s *Slack) sendMessage(channel, message string) (string, error) { log.Debug().Msgf("Sending message to %s: %s", channel, message) identifier, err := s.sendMessageType(channel, message, false) return identifier, err } func (s *Slack) sendAction(channel, message string) (string, error) { log.Debug().Msgf("Sending action to %s: %s", channel, message) identifier, err := s.sendMessageType(channel, "_"+message+"_", true) return identifier, err } func (s *Slack) replyToMessageIdentifier(channel, message, identifier string) (string, error) { nick := s.config.Get("Nick", "bot") icon := s.config.Get("IconURL", "https://placekitten.com/128/128") resp, err := http.PostForm("https://slack.com/api/chat.postMessage", url.Values{"token": {s.token}, "username": {nick}, "icon_url": {icon}, "channel": {channel}, "text": {message}, "thread_ts": {identifier}, }) if err != nil { err := fmt.Errorf("Error sending Slack reply: %s", err) return "", err } body, err := ioutil.ReadAll(resp.Body) resp.Body.Close() if err != nil { err := fmt.Errorf("Error reading Slack API body: %s", err) return "", err } log.Debug().Msgf("%s", body) type MessageResponse struct { OK bool `json:"ok"` Timestamp string `json:"ts"` } var mr MessageResponse err = json.Unmarshal(body, &mr) if err != nil { err := fmt.Errorf("Error parsing message response: %s", err) return "", err } if !mr.OK { return "", fmt.Errorf("Got !OK from slack message response") } return mr.Timestamp, err } func (s *Slack) replyToMessage(channel, message string, replyTo msg.Message) (string, error) { return s.replyToMessageIdentifier(channel, message, replyTo.AdditionalData["RAW_SLACK_TIMESTAMP"]) } func (s *Slack) react(channel, reaction string, message msg.Message) (string, error) { log.Debug().Msgf("Reacting in %s: %s", channel, reaction) resp, err := http.PostForm("https://slack.com/api/reactions.add", url.Values{"token": {s.token}, "name": {reaction}, "channel": {channel}, "timestamp": {message.AdditionalData["RAW_SLACK_TIMESTAMP"]}}) if err != nil { err := fmt.Errorf("reaction failed: %s", err) return "", err } return "", checkReturnStatus(resp) } func (s *Slack) edit(channel, newMessage, identifier string) (string, error) { log.Debug().Msgf("Editing in (%s) %s: %s", identifier, channel, newMessage) resp, err := http.PostForm("https://slack.com/api/chat.update", url.Values{"token": {s.token}, "channel": {channel}, "text": {newMessage}, "ts": {identifier}}) if err != nil { err := fmt.Errorf("edit failed: %s", err) return "", err } return "", checkReturnStatus(resp) } func (s *Slack) GetEmojiList() map[string]string { return s.emoji } func (s *Slack) populateEmojiList() { resp, err := http.PostForm("https://slack.com/api/emoji.list", url.Values{"token": {s.token}}) if err != nil { log.Debug().Msgf("Error retrieving emoji list from Slack: %s", err) return } body, err := ioutil.ReadAll(resp.Body) resp.Body.Close() if err != nil { log.Fatal().Err(err).Msgf("Error reading Slack API body") } type EmojiListResponse struct { OK bool `json:"ok"` Emoji map[string]string `json:"emoji"` } var list EmojiListResponse err = json.Unmarshal(body, &list) if err != nil { log.Fatal().Err(err).Msgf("Error parsing emoji list") } s.emoji = list.Emoji } func (s *Slack) ping(ctx context.Context) { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: ping := map[string]interface{}{"type": "ping", "time": time.Now().UnixNano()} if err := s.ws.Send(context.TODO(), ping); err != nil { panic(err) } } } } func (s *Slack) receiveMessage() (slackMessage, error) { m := slackMessage{} err := s.ws.Recv(context.TODO(), &m) if err != nil { log.Error().Msgf("Error decoding WS message") panic(fmt.Errorf("%v\n%v", m, err)) } return m, nil } // I think it's horseshit that I have to do this func slackTStoTime(t string) time.Time { ts := strings.Split(t, ".") sec, _ := strconv.ParseInt(ts[0], 10, 64) nsec, _ := strconv.ParseInt(ts[1], 10, 64) return time.Unix(sec, nsec) } func (s *Slack) Serve() error { s.connect() s.populateEmojiList() ctx, cancel := context.WithCancel(context.Background()) defer cancel() go s.ping(ctx) for { msg, err := s.receiveMessage() if err != nil && err == io.EOF { log.Fatal().Msg("Slack API EOF") } else if err != nil { return fmt.Errorf("Slack API error: %s", err) } switch msg.Type { case "message": isItMe := msg.BotID != "" && msg.BotID == s.myBotID if !isItMe && !msg.Hidden && msg.ThreadTs == "" { m := s.buildMessage(msg) if m.Time.Before(s.lastRecieved) { log.Debug().Msgf("Ignoring message: %+v\nlastRecieved: %v msg: %v", msg.ID, s.lastRecieved, m.Time) } else { s.lastRecieved = m.Time s.event(s, bot.Message, m) } } else if msg.ThreadTs != "" { //we're throwing away some information here by not parsing the correct reply object type, but that's okay s.event(s, bot.Reply, s.buildLightReplyMessage(msg), msg.ThreadTs) } else { log.Debug().Msgf("THAT MESSAGE WAS HIDDEN: %+v", msg.ID) } case "error": log.Error().Msgf("Slack error, code: %d, message: %s", msg.Error.Code, msg.Error.Msg) case "": // what even is this? case "hello": case "presence_change": case "user_typing": case "reconnect_url": case "desktop_notification": case "pong": // squeltch this stuff continue default: log.Debug().Msgf("Unhandled Slack message type: '%s'", msg.Type) } } } var urlDetector = regexp.MustCompile(`<(.+)://([^|^>]+).*>`) // Convert a slackMessage to a msg.Message func (s *Slack) buildMessage(m slackMessage) msg.Message { text := html.UnescapeString(m.Text) text = fixText(s.getUser, text) isCmd, text := bot.IsCmd(s.config, text) isAction := m.SubType == "me_message" u, _ := s.getUser(m.User) if m.Username != "" { u = m.Username } tstamp := slackTStoTime(m.Ts) return msg.Message{ User: &user.User{ ID: m.User, Name: u, }, Body: text, Raw: m.Text, Channel: m.Channel, Command: isCmd, Action: isAction, Host: string(m.ID), Time: tstamp, AdditionalData: map[string]string{ "RAW_SLACK_TIMESTAMP": m.Ts, }, } } func (s *Slack) buildLightReplyMessage(m slackMessage) msg.Message { text := html.UnescapeString(m.Text) text = fixText(s.getUser, text) isCmd, text := bot.IsCmd(s.config, text) isAction := m.SubType == "me_message" u, _ := s.getUser(m.User) if m.Username != "" { u = m.Username } tstamp := slackTStoTime(m.Ts) return msg.Message{ User: &user.User{ ID: m.User, Name: u, }, Body: text, Raw: m.Text, Channel: m.Channel, Command: isCmd, Action: isAction, Host: string(m.ID), Time: tstamp, AdditionalData: map[string]string{ "RAW_SLACK_TIMESTAMP": m.Ts, }, } } // markAllChannelsRead gets a list of all channels and marks each as read func (s *Slack) markAllChannelsRead() { chs := s.getAllChannels() log.Debug().Msgf("Got list of channels to mark read: %+v", chs) for _, ch := range chs { s.markChannelAsRead(ch.ID) } log.Debug().Msgf("Finished marking channels read") } // getAllChannels returns info for all channels joined func (s *Slack) getAllChannels() []slackChannelListItem { u := s.url + "channels.list" resp, err := http.PostForm(u, url.Values{"token": {s.token}}) if err != nil { log.Error().Err(err).Msgf("Error posting user info request") return nil } if resp.StatusCode != 200 { log.Error().Msgf("Error posting user info request: %d", resp.StatusCode) return nil } defer resp.Body.Close() var chanInfo slackChannelListResp err = json.NewDecoder(resp.Body).Decode(&chanInfo) if err != nil || !chanInfo.Ok { log.Error().Err(err).Msgf("Error decoding response") return nil } return chanInfo.Channels } // markAsRead marks a channel read func (s *Slack) markChannelAsRead(slackChanId string) error { u := s.url + "channels.info" resp, err := http.PostForm(u, url.Values{"token": {s.token}, "channel": {slackChanId}}) if err != nil { log.Error().Err(err).Msgf("Error posting user info request") return err } if resp.StatusCode != 200 { log.Error().Msgf("Error posting user info request: %d", resp.StatusCode) return err } defer resp.Body.Close() var chanInfo slackChannelInfoResp err = json.NewDecoder(resp.Body).Decode(&chanInfo) if err != nil || !chanInfo.Ok { log.Error().Err(err).Msgf("Error decoding response") return err } u = s.url + "channels.mark" resp, err = http.PostForm(u, url.Values{"token": {s.token}, "channel": {slackChanId}, "ts": {chanInfo.Channel.Latest.Ts}}) if err != nil { log.Error().Err(err).Msgf("Error posting user info request") return err } if resp.StatusCode != 200 { log.Error().Msgf("Error posting user info request: %d", resp.StatusCode) return err } defer resp.Body.Close() var markInfo map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&markInfo) if err != nil { log.Error().Err(err).Msgf("Error decoding response") return err } log.Info().Msgf("Marked %s as read", slackChanId) return nil } func (s *Slack) connect() { token := s.token u := fmt.Sprintf("https://slack.com/api/rtm.connect?token=%s", token) resp, err := http.Get(u) if err != nil { return } if resp.StatusCode != 200 { log.Fatal().Msgf("Slack API failed. Code: %d", resp.StatusCode) } body, err := ioutil.ReadAll(resp.Body) resp.Body.Close() if err != nil { log.Fatal().Err(err).Msg("Error reading Slack API body") } var rtm rtmStart err = json.Unmarshal(body, &rtm) if err != nil { return } if !rtm.Ok { log.Fatal().Msgf("Slack error: %s", rtm.Error) } s.url = "https://slack.com/api/" s.id = rtm.Self.ID // This is hitting the rate limit, and it may not be needed //s.markAllChannelsRead() rtmURL, _ := url.Parse(rtm.URL) s.ws, err = websocket.Dial(context.TODO(), rtmURL) if err != nil { log.Fatal().Err(err) } } // Get username for Slack user ID func (s *Slack) getUser(id string) (string, bool) { if name, ok := s.users[id]; ok { return name, true } log.Debug().Msgf("User %s not already found, requesting info", id) u := s.url + "users.info" resp, err := http.PostForm(u, url.Values{"token": {s.token}, "user": {id}}) if err != nil || resp.StatusCode != 200 { log.Error().Err(err).Msgf("Error posting user info request: %d", resp.StatusCode) return "UNKNOWN", false } defer resp.Body.Close() var userInfo slackUserInfoResp err = json.NewDecoder(resp.Body).Decode(&userInfo) if err != nil { log.Error().Err(err).Msgf("Error decoding response") return "UNKNOWN", false } s.users[id] = userInfo.User.Name return s.users[id], true } // Who gets usernames out of a channel func (s *Slack) Who(id string) []string { log.Debug(). Str("id", id). Msg("Who is queried for ") u := s.url + "channels.info" resp, err := http.PostForm(u, url.Values{"token": {s.token}, "channel": {id}}) if err != nil { log.Error().Err(err).Msgf("Error posting user info request") return []string{} } if resp.StatusCode != 200 { log.Error().Msgf("Error posting user info request: %d", resp.StatusCode) return []string{} } defer resp.Body.Close() var chanInfo slackChannelInfoResp err = json.NewDecoder(resp.Body).Decode(&chanInfo) if err != nil || !chanInfo.Ok { log.Error().Err(err).Msgf("Error decoding response") return []string{} } log.Debug().Msgf("%#v", chanInfo.Channel) handles := []string{} for _, member := range chanInfo.Channel.Members { u, _ := s.getUser(member) handles = append(handles, u) } log.Debug().Msgf("Returning %d handles", len(handles)) return handles } func (s *Slack) Profile(string) (user.User, error) { return user.User{}, fmt.Errorf("unimplemented") }