diff --git a/connectors/slack/fix_text.go b/connectors/slack/fix_text.go deleted file mode 100644 index 28a8c1e..0000000 --- a/connectors/slack/fix_text.go +++ /dev/null @@ -1,96 +0,0 @@ -package slack - -import ( - "unicode/utf8" -) - -// fixText strips all of the Slack-specific annotations from message text, -// replacing it with the equivalent display form. -// Currently it: -// • Replaces user mentions like <@U124356> with @ followed by the user's nick. -// This uses the lookupUser function, which must map U1243456 to the nick. -// • Replaces user mentions like with the user's nick. -// • Strips < and > surrounding links. -// -// This was directly bogarted from velour/chat with emoji conversion removed. -func fixText(findUser func(id string) (string, bool), text string) string { - var output []rune - for len(text) > 0 { - r, i := utf8.DecodeRuneInString(text) - text = text[i:] - switch { - case r == '<': - var tag []rune - for { - r, i := utf8.DecodeRuneInString(text) - text = text[i:] - switch { - case r == '>': - if t, ok := fixTag(findUser, tag); ok { - output = append(output, t...) - break - } - fallthrough - case len(text) == 0: - output = append(output, '<') - output = append(output, tag...) - output = append(output, r) - default: - tag = append(tag, r) - continue - } - break - } - default: - output = append(output, r) - } - } - return string(output) -} - -func fixTag(findUser func(string) (string, bool), tag []rune) ([]rune, bool) { - switch { - case hasPrefix(tag, "@U"): - if i := indexRune(tag, '|'); i >= 0 { - return tag[i+1:], true - } - if findUser != nil { - if u, ok := findUser(string(tag[1:])); ok { - return []rune(u), true - } - } - return tag, true - - case hasPrefix(tag, "#C"): - if i := indexRune(tag, '|'); i >= 0 { - return append([]rune{'#'}, tag[i+1:]...), true - } - - case hasPrefix(tag, "http"): - if i := indexRune(tag, '|'); i >= 0 { - tag = tag[:i] - } - return tag, true - } - - return nil, false -} - -func hasPrefix(text []rune, prefix string) bool { - for _, r := range prefix { - if len(text) == 0 || text[0] != r { - return false - } - text = text[1:] - } - return true -} - -func indexRune(text []rune, find rune) int { - for i, r := range text { - if r == find { - return i - } - } - return -1 -} diff --git a/connectors/slack/slack.go b/connectors/slack/slack.go deleted file mode 100644 index 377b91e..0000000 --- a/connectors/slack/slack.go +++ /dev/null @@ -1,773 +0,0 @@ -// © 2016 the CatBase Authors under the WTFPL license. See AUTHORS for the list of authors. - -// Package slack connects to slack service -package slack - -import ( - // "sync/atomic" - "context" - "encoding/json" - "errors" - "fmt" - "html" - "io" - "io/ioutil" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "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) GetRouter() (http.Handler, string) { - return nil, "" -} - -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: fmt.Sprint(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: fmt.Sprint(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") -} - -// GetChannelName returns the channel ID for a human-friendly name (if possible) -func (s *Slack) GetChannelID(name string) string { - chs := s.getAllChannels() - for _, ch := range chs { - if ch.Name == name { - return ch.ID - } - } - return name -} - -// GetChannelName returns the human-friendly name for an ID (if possible) -func (s *Slack) GetChannelName(id string) string { - chs := s.getAllChannels() - for _, ch := range chs { - if ch.ID == id { - return ch.Name - } - } - return id -} - -func (s *Slack) Emojy(name string) string { - e := s.config.GetMap("slack.emojy", map[string]string{}) - if emojy, ok := e[name]; ok { - return emojy - } - return name -} - -func (s *Slack) URLFormat(title, url string) string { - return fmt.Sprintf("<%s|%s>", url, title) -}