2017-12-15 16:37:01 +00:00
|
|
|
package slack
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"os"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/nlopes/slack"
|
|
|
|
|
|
|
|
"github.com/keel-hq/keel/bot"
|
|
|
|
"github.com/keel-hq/keel/constants"
|
2018-06-19 23:06:38 +00:00
|
|
|
"github.com/keel-hq/keel/version"
|
2017-12-15 16:37:01 +00:00
|
|
|
|
2018-03-03 11:32:00 +00:00
|
|
|
log "github.com/sirupsen/logrus"
|
2017-12-15 16:37:01 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// SlackImplementer - implementes slack HTTP functionality, used to
|
|
|
|
// send messages with attachments
|
|
|
|
type SlackImplementer interface {
|
2019-04-26 22:48:12 +00:00
|
|
|
PostMessage(channelID string, options ...slack.MsgOption) (string, string, error)
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Bot - main slack bot container
|
|
|
|
type Bot struct {
|
|
|
|
id string // bot id
|
|
|
|
name string // bot name
|
|
|
|
|
|
|
|
users map[string]string
|
|
|
|
|
|
|
|
msgPrefix string
|
|
|
|
|
|
|
|
slackClient *slack.Client
|
|
|
|
slackRTM *slack.RTM
|
|
|
|
|
|
|
|
slackHTTPClient SlackImplementer
|
|
|
|
|
|
|
|
approvalsChannel string // slack approvals channel name
|
|
|
|
|
2017-12-27 13:46:38 +00:00
|
|
|
ctx context.Context
|
|
|
|
botMessagesChannel chan *bot.BotMessage
|
|
|
|
approvalsRespCh chan *bot.ApprovalResponse
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func init() {
|
2017-12-27 13:46:38 +00:00
|
|
|
bot.RegisterBot("slack", &Bot{})
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
2017-12-27 13:46:38 +00:00
|
|
|
func (b *Bot) Configure(approvalsRespCh chan *bot.ApprovalResponse, botMessagesChannel chan *bot.BotMessage) bool {
|
2017-12-15 16:37:01 +00:00
|
|
|
if os.Getenv(constants.EnvSlackToken) != "" {
|
|
|
|
|
2017-12-27 13:46:38 +00:00
|
|
|
b.name = "keel"
|
2019-02-28 22:05:22 +00:00
|
|
|
if bootName := os.Getenv(constants.EnvSlackBotName); bootName != "" {
|
|
|
|
b.name = bootName
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
token := os.Getenv(constants.EnvSlackToken)
|
2017-12-27 13:46:38 +00:00
|
|
|
client := slack.New(token)
|
2017-12-15 16:37:01 +00:00
|
|
|
|
2017-12-27 13:46:38 +00:00
|
|
|
b.approvalsChannel = "general"
|
2019-02-28 22:05:22 +00:00
|
|
|
if channel := os.Getenv(constants.EnvSlackApprovalsChannel); channel != "" {
|
2019-04-16 21:51:27 +00:00
|
|
|
b.approvalsChannel = strings.TrimPrefix(channel, "#")
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
2017-12-27 13:46:38 +00:00
|
|
|
b.slackClient = client
|
|
|
|
b.slackHTTPClient = client
|
|
|
|
b.approvalsRespCh = approvalsRespCh
|
|
|
|
b.botMessagesChannel = botMessagesChannel
|
2017-12-15 16:37:01 +00:00
|
|
|
|
2017-12-27 13:46:38 +00:00
|
|
|
return true
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
2017-12-27 13:46:38 +00:00
|
|
|
log.Info("bot.slack.Configure(): Slack approval bot is not configured")
|
|
|
|
return false
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Start - start bot
|
|
|
|
func (b *Bot) Start(ctx context.Context) error {
|
|
|
|
// setting root context
|
|
|
|
b.ctx = ctx
|
|
|
|
|
|
|
|
users, err := b.slackClient.GetUsers()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
b.users = map[string]string{}
|
|
|
|
|
|
|
|
for _, user := range users {
|
|
|
|
switch user.Name {
|
|
|
|
case b.name:
|
|
|
|
if user.IsBot {
|
|
|
|
b.id = user.ID
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if b.id == "" {
|
|
|
|
return errors.New("could not find bot in the list of names, check if the bot is called \"" + b.name + "\" ")
|
|
|
|
}
|
|
|
|
|
|
|
|
b.msgPrefix = strings.ToLower("<@" + b.id + ">")
|
|
|
|
|
|
|
|
go b.startInternal()
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *Bot) startInternal() error {
|
|
|
|
b.slackRTM = b.slackClient.NewRTM()
|
|
|
|
|
|
|
|
go b.slackRTM.ManageConnection()
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-b.ctx.Done():
|
|
|
|
return nil
|
|
|
|
|
|
|
|
case msg := <-b.slackRTM.IncomingEvents:
|
|
|
|
switch ev := msg.Data.(type) {
|
|
|
|
case *slack.HelloEvent:
|
|
|
|
// Ignore hello
|
|
|
|
case *slack.ConnectedEvent:
|
2019-06-02 10:13:33 +00:00
|
|
|
// nothing to do
|
2017-12-15 16:37:01 +00:00
|
|
|
case *slack.MessageEvent:
|
|
|
|
b.handleMessage(ev)
|
|
|
|
case *slack.PresenceChangeEvent:
|
2019-06-02 10:13:33 +00:00
|
|
|
// nothing to do
|
2017-12-15 16:37:01 +00:00
|
|
|
case *slack.RTMError:
|
2019-07-07 14:27:47 +00:00
|
|
|
log.Errorf("Error: %s", ev.Error())
|
2017-12-15 16:37:01 +00:00
|
|
|
case *slack.InvalidAuthEvent:
|
2019-06-02 10:13:33 +00:00
|
|
|
log.Error("Invalid credentials")
|
2017-12-15 16:37:01 +00:00
|
|
|
return fmt.Errorf("invalid credentials")
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
|
|
// Ignore other events..
|
|
|
|
// fmt.Printf("Unexpected: %v\n", msg.Data)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *Bot) postMessage(title, message, color string, fields []slack.AttachmentField) error {
|
|
|
|
params := slack.NewPostMessageParameters()
|
|
|
|
params.Username = b.name
|
2019-10-10 14:40:04 +00:00
|
|
|
params.IconURL = b.getBotUserIconURL()
|
2017-12-15 16:37:01 +00:00
|
|
|
|
2019-04-26 22:48:12 +00:00
|
|
|
attachements := []slack.Attachment{
|
2020-06-12 23:14:11 +00:00
|
|
|
{
|
2017-12-15 16:37:01 +00:00
|
|
|
Fallback: message,
|
|
|
|
Color: color,
|
|
|
|
Fields: fields,
|
2018-06-19 23:06:38 +00:00
|
|
|
Footer: fmt.Sprintf("https://keel.sh %s", version.GetKeelVersion().Version),
|
2017-12-15 16:37:01 +00:00
|
|
|
Ts: json.Number(strconv.Itoa(int(time.Now().Unix()))),
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2019-04-26 22:48:12 +00:00
|
|
|
var mgsOpts []slack.MsgOption
|
|
|
|
|
|
|
|
mgsOpts = append(mgsOpts, slack.MsgOptionPostMessageParameters(params))
|
|
|
|
mgsOpts = append(mgsOpts, slack.MsgOptionAttachments(attachements...))
|
|
|
|
|
|
|
|
_, _, err := b.slackHTTPClient.PostMessage(b.approvalsChannel, mgsOpts...)
|
2017-12-15 16:37:01 +00:00
|
|
|
if err != nil {
|
|
|
|
log.WithFields(log.Fields{
|
2019-06-02 10:13:33 +00:00
|
|
|
"error": err,
|
|
|
|
"approvals_channel": b.approvalsChannel,
|
2017-12-15 16:37:01 +00:00
|
|
|
}).Error("bot.postMessage: failed to send message")
|
|
|
|
}
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-26 22:48:12 +00:00
|
|
|
// checking if message was received in approvals channel
|
2017-12-15 16:37:01 +00:00
|
|
|
func (b *Bot) isApprovalsChannel(event *slack.MessageEvent) bool {
|
2018-06-03 11:20:52 +00:00
|
|
|
|
2019-04-26 22:48:12 +00:00
|
|
|
channel, err := b.slackClient.GetChannelInfo(event.Channel)
|
|
|
|
if err != nil {
|
2019-06-02 10:13:33 +00:00
|
|
|
// looking for private channel
|
|
|
|
conv, err := b.slackRTM.GetConversationInfo(event.Channel, true)
|
|
|
|
if err != nil {
|
|
|
|
log.Errorf("couldn't find amongst private conversations: %s", err)
|
|
|
|
} else if conv.Name == b.approvalsChannel {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2019-04-26 22:48:12 +00:00
|
|
|
log.WithError(err).Errorf("channel with ID %s could not be retrieved", event.Channel)
|
|
|
|
return false
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
2018-06-03 11:20:52 +00:00
|
|
|
|
2019-04-26 22:48:12 +00:00
|
|
|
log.Debugf("checking if approvals channel: %s==%s", channel.Name, b.approvalsChannel)
|
|
|
|
if channel.Name == b.approvalsChannel {
|
|
|
|
return true
|
2018-06-03 11:20:52 +00:00
|
|
|
}
|
|
|
|
|
2019-04-26 22:48:12 +00:00
|
|
|
log.Debugf("message was received not on approvals channel (%s)", channel.Name)
|
|
|
|
|
2017-12-15 16:37:01 +00:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *Bot) handleMessage(event *slack.MessageEvent) {
|
|
|
|
if event.BotID != "" || event.User == "" || event.SubType == "bot_message" {
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
"event_bot_ID": event.BotID,
|
|
|
|
"event_user": event.User,
|
2018-06-03 11:20:52 +00:00
|
|
|
"msg": event.Text,
|
2017-12-15 16:37:01 +00:00
|
|
|
"event_subtype": event.SubType,
|
2018-08-29 22:40:03 +00:00
|
|
|
}).Debug("handleMessage: ignoring message")
|
2017-12-15 16:37:01 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
eventText := strings.Trim(strings.ToLower(event.Text), " \n\r")
|
|
|
|
|
|
|
|
if !b.isBotMessage(event, eventText) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
eventText = b.trimBot(eventText)
|
|
|
|
|
2019-04-26 22:48:12 +00:00
|
|
|
approval, ok := bot.IsApproval(event.User, eventText)
|
2017-12-15 16:37:01 +00:00
|
|
|
// only accepting approvals from approvals channel
|
2019-04-26 22:48:12 +00:00
|
|
|
if ok && b.isApprovalsChannel(event) {
|
|
|
|
b.approvalsRespCh <- approval
|
|
|
|
return
|
|
|
|
} else if ok {
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
"received_on": event.Channel,
|
|
|
|
"approvals_chan": b.approvalsChannel,
|
|
|
|
}).Warnf("message was received not in approvals channel: %s", event.Channel)
|
|
|
|
b.Respond(fmt.Sprintf("please use approvals channel '%s'", b.approvalsChannel), event.Channel)
|
|
|
|
return
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
2017-12-27 13:46:38 +00:00
|
|
|
b.botMessagesChannel <- &bot.BotMessage{
|
|
|
|
Message: eventText,
|
|
|
|
User: event.User,
|
|
|
|
Channel: event.Channel,
|
|
|
|
Name: "slack",
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-12-27 13:46:38 +00:00
|
|
|
func (b *Bot) Respond(text string, channel string) {
|
2019-04-27 09:54:58 +00:00
|
|
|
|
|
|
|
// if message is short, replying directly via slack RTM
|
|
|
|
if len(text) < 3000 {
|
|
|
|
b.slackRTM.SendMessage(b.slackRTM.NewOutgoingMessage(formatAsSnippet(text), channel))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// longer messages are getting uploaded as files
|
|
|
|
|
|
|
|
f := slack.FileUploadParameters{
|
|
|
|
Filename: "keel response",
|
|
|
|
Content: text,
|
|
|
|
Filetype: "text",
|
|
|
|
Channels: []string{channel},
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err := b.slackClient.UploadFile(f)
|
|
|
|
if err != nil {
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
"error": err,
|
|
|
|
}).Error("Respond: failed to send message")
|
|
|
|
}
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (b *Bot) isBotMessage(event *slack.MessageEvent, eventText string) bool {
|
|
|
|
prefixes := []string{
|
|
|
|
b.msgPrefix,
|
2019-04-27 09:54:58 +00:00
|
|
|
b.name,
|
|
|
|
// "kel",
|
2017-12-15 16:37:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
for _, p := range prefixes {
|
|
|
|
if strings.HasPrefix(eventText, p) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Direct message channels always starts with 'D'
|
|
|
|
return strings.HasPrefix(event.Channel, "D")
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *Bot) trimBot(msg string) string {
|
|
|
|
msg = strings.Replace(msg, strings.ToLower(b.msgPrefix), "", 1)
|
|
|
|
msg = strings.TrimPrefix(msg, b.name)
|
|
|
|
msg = strings.Trim(msg, " :\n")
|
|
|
|
|
|
|
|
return msg
|
|
|
|
}
|
|
|
|
|
|
|
|
func formatAsSnippet(response string) string {
|
|
|
|
return "```" + response + "```"
|
|
|
|
}
|
2019-10-10 14:40:04 +00:00
|
|
|
|
|
|
|
func (b *Bot) getBotUserIconURL() string {
|
|
|
|
res, err := b.slackClient.GetUserInfo(b.id)
|
|
|
|
if err != nil {
|
|
|
|
log.WithFields(log.Fields{
|
|
|
|
"error": err,
|
|
|
|
"bot_id": b.id,
|
|
|
|
}).Error("bot.postMessage: failed to retrieve bot user icon url")
|
|
|
|
}
|
|
|
|
|
|
|
|
return res.Profile.ImageOriginal
|
|
|
|
}
|