catbase/connectors/slackapp/slackApp.go

758 lines
19 KiB
Go
Raw Normal View History

package slackapp
import (
"bytes"
"container/ring"
"encoding/json"
"fmt"
"html"
"io/ioutil"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strconv"
"strings"
"text/template"
"time"
"github.com/go-chi/chi/v5"
zerowidth "github.com/trubitsyn/go-zero-width"
"github.com/rs/zerolog/log"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
"github.com/velour/catbase/bot/user"
"github.com/velour/catbase/config"
)
const DefaultIDRing = 10
const DefaultMsgRing = 50
const defaultLogFormat = "[{{fixDate .Time \"2006-01-02 15:04:05\"}}] {{if .TopicChange}}*** {{.User.Name}}{{else if .Action}}* {{.User.Name}}{{else}}<{{.User.Name}}>{{end}} {{.Body}}\n"
// 11:10AM DBG connectors/slackapp/slackApp.go:496 > Slack event dir=logs raw={"Action":false,"AdditionalData":
// {"RAW_SLACK_TIMESTAMP":"1559920235.001100"},"Body":"aoeu","Channel":"C0S04SMRC","ChannelName":"test",
// "Command":false,"Host":"","IsIM":false,"Raw":{"channel":"C0S04SMRC","channel_type":"channel",
// "event_ts":1559920235.001100,"files":null,"text":"aoeu","thread_ts":"","ts":"1559920235.001100",
// "type":"message","upload":false,"user":"U0RLUDELD"},"Time":"2019-06-07T11:10:35.0000011-04:00",
// "User":{"Admin":false,"ID":"U0RLUDELD","Name":"flyngpngn"}}
type SlackApp struct {
config *config.Config
api *slack.Client
router *chi.Mux
botToken string
userToken string
verification string
id string
lastRecieved time.Time
myBotID string
users map[string]*slack.User
emoji map[string]string
channels map[string]*slack.Channel
event bot.Callback
msgIDBuffer *ring.Ring
msgBuffer *ring.Ring
logFormat *template.Template
}
func fixDate(input time.Time, format string) string {
return input.Format(format)
}
func New(c *config.Config) *SlackApp {
token := c.Get("slack.token", "NONE")
if token == "NONE" {
log.Fatal().Msg("No slack token found. Set SLACKTOKEN env.")
}
api := slack.New(token, slack.OptionDebug(false))
tplTxt := c.GetString("slackapp.logMessage.format", defaultLogFormat)
funcs := template.FuncMap{
"fixDate": fixDate,
}
tpl := template.Must(template.New("logMessage").Funcs(funcs).Parse(tplTxt))
return &SlackApp{
api: api,
router: chi.NewRouter(),
config: c,
botToken: token,
userToken: c.Get("slack.usertoken", "NONE"),
verification: c.Get("slack.verification", "NONE"),
myBotID: c.Get("slack.botid", ""),
lastRecieved: time.Now(),
users: make(map[string]*slack.User),
emoji: make(map[string]string),
channels: make(map[string]*slack.Channel),
msgIDBuffer: ring.New(c.GetInt("idRingSize", DefaultIDRing)),
msgBuffer: ring.New(c.GetInt("msgRingSize", DefaultMsgRing)),
logFormat: tpl,
}
}
func (s *SlackApp) RegisterEvent(f bot.Callback) {
s.event = f
}
func (s *SlackApp) GetRouter() (http.Handler, string) {
return s.router, "/evt"
}
func (s *SlackApp) Serve() error {
s.populateEmojiList()
s.router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
buf := new(bytes.Buffer)
buf.ReadFrom(r.Body)
body := buf.String()
eventsAPIEvent, e := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionVerifyToken(&slackevents.TokenComparator{VerificationToken: s.verification}))
if e != nil {
log.Error().Err(e)
w.WriteHeader(http.StatusInternalServerError)
}
if eventsAPIEvent.Type == slackevents.URLVerification {
var r *slackevents.ChallengeResponse
err := json.Unmarshal([]byte(body), &r)
if err != nil {
log.Error().Err(err)
w.WriteHeader(http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "text")
w.Write([]byte(r.Challenge))
} else if eventsAPIEvent.Type == slackevents.CallbackEvent {
innerEvent := eventsAPIEvent.InnerEvent
typ := innerEvent.Type
switch ev := innerEvent.Data.(type) {
case *slackevents.MessageAction:
log.Debug().Interface("ev", ev).Msg("MessageAction")
case *slackevents.MessageActionResponse:
log.Debug().Interface("ev", ev).Msg("MessageActionResponse")
case *slackevents.AppMentionEvent:
// This is a bit of a problem. AppMentionEvent also needs to
// End up in msgReceived
//s.msgReceivd(ev)
case *slackevents.MessageEvent:
s.msgReceivd(ev)
case *slackevents.ReactionAddedEvent:
err := s.reactionReceived(ev)
if err != nil {
log.Error().Err(err).Msg("error with reaction recording")
}
default:
log.Debug().
Interface("ev", ev).
Interface("type", typ).
Msg("Unknown CallbackEvent")
}
} else {
log.Debug().
Str("type", eventsAPIEvent.Type).
Interface("event", eventsAPIEvent).
Msg("event")
}
})
return nil
}
// checkRingOrAdd returns true if it finds the ts value
// or false if the ts isn't yet in the ring (and adds it)
func (s *SlackApp) checkRingOrAdd(ts string) bool {
found := false
s.msgIDBuffer.Do(func(p any) {
if p == nil {
return
}
if p.(string) == ts {
found = true
}
})
if found {
return true
}
s.msgIDBuffer.Value = ts
s.msgIDBuffer = s.msgIDBuffer.Next()
return false
}
func (s *SlackApp) msgReceivd(msg *slackevents.MessageEvent) {
if msg.TimeStamp == "" {
log.Debug().
Str("type", msg.SubType).
Msg("ignoring an unhandled event type")
return
}
log.Debug().
Interface("event", msg).
Str("type", msg.SubType).
Msg("accepting a message")
if s.checkRingOrAdd(msg.TimeStamp) {
log.Debug().
Str("ts", msg.TimeStamp).
Msg("Got a duplicate message from server")
return
}
isItMe := msg.BotID != "" && msg.BotID == s.myBotID
m := s.buildMessage(msg)
if m.Time.Before(s.lastRecieved) {
log.Debug().
Time("ts", m.Time).
Interface("lastRecv", s.lastRecieved).
Msg("Ignoring message")
return
}
if err := s.logMessage(m); err != nil {
log.Fatal().Err(err).Msg("Error logging message")
}
if !isItMe && msg.ThreadTimeStamp == "" {
s.lastRecieved = m.Time
s.event(s, bot.Message, m)
} else if msg.ThreadTimeStamp != "" {
//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.buildMessage(msg), msg.ThreadTimeStamp)
} else if isItMe {
s.event(s, bot.SelfMessage, m)
} else {
log.Debug().
Str("text", msg.Text).
Msg("Unknown message is hidden")
}
s.msgBuffer.Value = msg
s.msgBuffer = s.msgBuffer.Next()
}
func (s *SlackApp) Send(kind bot.Kind, args ...any) (string, error) {
switch kind {
case bot.Message:
return s.sendMessage(args[0].(string), args[1].(string), false, args...)
case bot.Ephemeral:
args[1] = bot.EphemeralID(args[1].(string))
return s.sendMessage(args[0].(string), args[2].(string), false, args...)
case bot.Action:
return s.sendMessage(args[0].(string), args[1].(string), true, args...)
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 (s *SlackApp) sendMessage(channel, message string, meMessage bool, args ...any) (string, error) {
ts, err := "", fmt.Errorf("")
nick := s.config.Get("Nick", "bot")
options := []slack.MsgOption{
slack.MsgOptionUsername(nick),
slack.MsgOptionText(message, false),
}
if meMessage {
options = append(options, slack.MsgOptionMeMessage())
}
// Check for message attachments
attachments := []slack.Attachment{}
if len(args) > 0 {
for _, a := range args {
switch a := a.(type) {
case bot.EphemeralID:
options = append(options, slack.MsgOptionPostEphemeral(string(a)))
case bot.ImageAttachment:
attachments = append(attachments, slack.Attachment{
ImageURL: a.URL,
Text: a.AltTxt,
})
case bot.UnfurlLinks:
if a {
options = append(options, slack.MsgOptionEnableLinkUnfurl())
} else {
options = append(options, slack.MsgOptionDisableLinkUnfurl())
}
}
}
}
if len(attachments) > 0 {
options = append(options, slack.MsgOptionAttachments(attachments...))
}
log.Debug().
Str("channel", channel).
Str("message", message).
Int("attachment count", len(attachments)).
Int("option count", len(options)).
Int("arg count", len(args)).
Msg("Sending message")
_, ts, err = s.api.PostMessage(channel, options...)
if err != nil {
log.Error().Err(err).Msg("Error sending message")
return "", err
}
return ts, nil
}
func (s *SlackApp) replyToMessageIdentifier(channel, message, identifier string) (string, error) {
nick := s.config.Get("Nick", "bot")
resp, err := http.PostForm("https://slack.com/api/chat.postMessage",
url.Values{"token": {s.botToken},
"username": {nick},
"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().Bytes("body", 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 *SlackApp) replyToMessage(channel, message string, replyTo msg.Message) (string, error) {
return s.replyToMessageIdentifier(channel, message, replyTo.AdditionalData["RAW_SLACK_TIMESTAMP"])
}
func (s *SlackApp) react(channel, reaction string, message msg.Message) (string, error) {
log.Debug().
Str("channel", channel).
Str("reaction", reaction).
Msg("reacting")
ref := slack.ItemRef{
Channel: channel,
Timestamp: message.AdditionalData["RAW_SLACK_TIMESTAMP"],
}
err := s.api.AddReaction(reaction, ref)
return "", err
}
func (s *SlackApp) edit(channel, newMessage, identifier string) (string, error) {
log.Debug().
Str("channel", channel).
Str("identifier", identifier).
Str("newMessage", newMessage).
Msg("editing")
nick := s.config.Get("Nick", "bot")
_, ts, err := s.api.PostMessage(channel,
slack.MsgOptionUsername(nick),
slack.MsgOptionText(newMessage, false),
slack.MsgOptionMeMessage(),
slack.MsgOptionUpdate(identifier))
return ts, err
}
func (s *SlackApp) GetEmojiList(force bool) map[string]string {
if force {
s.populateEmojiList()
}
return s.emoji
}
func (s *SlackApp) populateEmojiList() {
if s.userToken == "NONE" {
log.Error().Msg("Cannot get emoji list without slack.usertoken")
return
}
api := slack.New(s.userToken, slack.OptionDebug(false))
em, err := api.GetEmoji()
if err != nil {
log.Error().Err(err).Msg("Error retrieving emoji list from Slack")
return
}
s.emoji = em
}
// I think it's horseshit that I have to do this
func slackTStoTime(t string) time.Time {
ts := strings.Split(t, ".")
if len(ts) < 2 {
log.Fatal().
Str("ts", t).
Msg("Could not parse Slack timestamp")
}
sec, _ := strconv.ParseInt(ts[0], 10, 64)
nsec, _ := strconv.ParseInt(ts[1], 10, 64)
return time.Unix(sec, nsec)
}
var urlDetector = regexp.MustCompile(`<(.+)://([^|^>]+).*>`)
// Convert a slackMessage to a msg.Message
func (s *SlackApp) buildMessage(m *slackevents.MessageEvent) msg.Message {
text := html.UnescapeString(m.Text)
text = fixText(s.getUser, text)
// Slack likes to put these NBSP in and it screws with matching later
text = strings.ReplaceAll(text, "\u00a0", " ")
text = strings.ReplaceAll(text, "\ufe0f", "")
text = zerowidth.RemoveZeroWidthCharacters(text)
isCmd, text := bot.IsCmd(s.config, text)
isAction := m.SubType == "me_message"
// We have to try a few layers to get a valid name for the user because Slack
defaultName := "unknown"
if m.BotID != "" {
defaultName = m.Username
}
icon := ""
log.Debug().Msgf("Getting user %s", m.User)
name, u := s.getUser(m.User, defaultName)
if u != nil {
icon = u.Profile.Image192
}
if m.Username != "" && name == "unknown" {
name = m.Username
}
chName := m.Channel
if ch, _ := s.getChannel(m.Channel); ch != nil {
chName = ch.Name
}
tstamp := slackTStoTime(m.TimeStamp)
return msg.Message{
ID: m.TimeStamp,
Kind: bot.Message,
User: &user.User{
ID: m.User,
Name: name,
Icon: icon,
},
Body: text,
Raw: m,
Channel: m.Channel,
ChannelName: chName,
IsIM: m.ChannelType == "im",
Command: isCmd,
Action: isAction,
Time: tstamp,
AdditionalData: map[string]string{
"RAW_SLACK_TIMESTAMP": m.TimeStamp,
},
}
}
func (s *SlackApp) getChannel(id string) (*slack.Channel, error) {
if ch, ok := s.channels[id]; ok {
return ch, nil
}
log.Debug().
Str("id", id).
Msg("Channel not known, requesting info")
ch, err := s.api.GetConversationInfo(id, false)
if err != nil {
return nil, err
}
s.channels[id] = ch
return s.channels[id], nil
}
// GetChannelName returns the channel ID for a human-friendly name (if possible)
func (s *SlackApp) GetChannelID(name string) string {
for _, ch := range s.channels {
if ch.Name == name {
return ch.ID
}
}
return name
}
// GetChannelName returns the human-friendly name for an ID (if possible)
func (s *SlackApp) GetChannelName(id string) string {
ch, err := s.getChannel(id)
if err != nil {
log.Error().Err(err).Msgf("Could not get channel name for %s", id)
return id
}
return ch.Name
}
func stringForUser(user *slack.User) string {
if user.Profile.DisplayName != "" {
return user.Profile.DisplayName
}
return user.Name
}
// Get username for Slack user ID
func (s *SlackApp) getUser(id, defaultName string) (string, *slack.User) {
if u, ok := s.users[id]; ok {
return stringForUser(u), u
}
log.Debug().
Str("id", id).
Msg("User not already found, requesting info")
u, err := s.api.GetUserInfo(id)
if err != nil {
return defaultName, nil
}
s.users[id] = u
return stringForUser(u), u
}
// Who gets usernames out of a channel
func (s *SlackApp) Who(id string) []string {
if s.userToken == "NONE" {
log.Error().Msg("Cannot get emoji list without slack.usertoken")
return []string{s.config.Get("nick", "bot")}
}
api := slack.New(s.userToken, slack.OptionDebug(false))
log.Debug().
Str("id", id).
Msg("Who is queried")
// Not super sure this is the correct call
params := &slack.GetUsersInConversationParameters{
ChannelID: id,
Limit: 50,
}
members, _, err := api.GetUsersInConversation(params)
if err != nil {
log.Error().Err(err)
return []string{s.config.Get("nick", "bot")}
}
ret := []string{}
for _, m := range members {
if m == "" {
log.Error().Msg("empty member")
}
u, _ := s.getUser(m, "unknown")
if u == "unknown" {
log.Error().
Err(err).
Str("user", m).
Msg("Couldn't get user")
continue
/**/
}
ret = append(ret, u)
}
return ret
}
// logMessage writes to a <slackapp.logMessage.dir>/<channel>.logMessage
// Uses slackapp.logMessage.format to write entries
func (s *SlackApp) logMessage(raw msg.Message) error {
// Do some filtering and fixing up front
if raw.Body == "" {
return nil
}
data := struct {
msg.Message
TopicChange bool
}{
Message: raw,
}
if strings.Contains(raw.Body, "set the channel topic: ") {
topic := strings.SplitN(raw.Body, "set the channel topic: ", 2)
data.Body = "changed topic to " + topic[1]
data.TopicChange = true
}
buf := bytes.Buffer{}
if err := s.logFormat.Execute(&buf, data); err != nil {
return err
}
return s.log(buf.String(), raw.ChannelName)
}
func (s *SlackApp) log(msg, channel string) error {
dir := path.Join(s.config.Get("slackapp.logMessage.dir", "logs"), channel)
now := time.Now()
fname := now.Format("20060102") + ".log"
path := path.Join(dir, fname)
log.Debug().
Interface("raw", msg).
Str("dir", dir).
Msg("Slack event")
if err := os.MkdirAll(dir, 0755); err != nil {
log.Error().
Err(err).
Msg("Could not create logMessage directory")
return err
}
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Fatal().
Err(err).
Msg("Error opening logMessage file")
}
defer f.Close()
if _, err := fmt.Fprint(f, msg); err != nil {
return err
}
return f.Sync()
}
func (s *SlackApp) reactionReceived(event *slackevents.ReactionAddedEvent) error {
log.Debug().Msgf("reactionReceived(%+v)", event)
name, _ := s.getUser(event.User, "unknown")
ch, err := s.getChannel(event.Item.Channel)
if err != nil {
return err
}
channel := ch.Name
body := "unknown message"
// Iterate through the ring and print its contents
s.msgBuffer.Do(func(p any) {
switch m := p.(type) {
case nil:
case *slackevents.MessageEvent:
if m.TimeStamp == event.Item.Timestamp {
defaultName := "unknown"
if m.BotID != "" {
defaultName = m.Username
}
u, _ := s.getUser(m.User, defaultName)
body = fmt.Sprintf("%s: %s", u, m.Text)
}
default:
log.Debug().Interface("msg", m).Msg("Unexpected type in reaction received")
}
})
tstamp := slackTStoTime(event.EventTimestamp)
msg := fmt.Sprintf("[%s] <%s> reacted to %s with :%s:\n",
fixDate(tstamp, "2006-01-02 15:04:05"),
name, body, event.Reaction)
log.Debug().Msgf("Made it to reaction received, logging %v: %v", msg, channel)
return s.log(msg, channel)
}
func (s *SlackApp) Profile(identifier string) (user.User, error) {
log.Debug().Msgf("Getting profile for %s", identifier)
users, err := s.api.GetUsers()
if err != nil {
return user.User{}, err
}
for _, u := range users {
if u.Name == identifier || u.ID == identifier {
return user.User{
ID: u.ID,
Name: stringForUser(&u),
Admin: false,
Icon: u.Profile.Image192,
}, nil
}
}
return user.User{}, fmt.Errorf("user %s not found", err)
}
func (s *SlackApp) Emojy(name string) string {
e := s.config.GetMap("slack.emojy", map[string]string{})
if emojy, ok := e[name]; ok {
log.Debug().Msgf("Found emoji %s for %s", emojy, name)
return emojy
}
log.Debug().Msgf("Found no emojy for %s", name)
return name
}
func (s *SlackApp) UploadEmojy(emojy, path string) error {
return fmt.Errorf("unimplemented")
}
func (s *SlackApp) DeleteEmojy(emojy string) error {
return fmt.Errorf("unimplemented")
}
func (s *SlackApp) URLFormat(title, url string) string {
return fmt.Sprintf("<%s|%s>", url, title)
}
func (s *SlackApp) GetRoles() ([]bot.Role, error) {
return []bot.Role{}, nil
}
func (s *SlackApp) SetRole(userID, roleID string) error {
return nil
}
func (s *SlackApp) Shutdown() {}
func (s *SlackApp) Nick(nick string) error {
return s.api.SetUserRealName(nick)
}