keel/bot/slack/slack.go

430 lines
13 KiB
Go

package slack
import (
"context"
"errors"
"fmt"
"github.com/keel-hq/keel/bot"
"github.com/keel-hq/keel/constants"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"os"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
// Bot - main slack bot container
type Bot struct {
id string // bot id
name string // bot name
users map[string]string
msgPrefix string
slackSocket *socketmode.Client
// the approval channel-name, provided by the bot configuration
approvalsChannel string
// the identifier of the approval channel, this is retrieved when the bot is starting
approvalChannelId string
ctx context.Context
botMessagesChannel chan *bot.BotMessage
approvalsRespCh chan *bot.ApprovalResponse
}
func init() {
bot.RegisterBot("slack", &Bot{})
}
func (b *Bot) Configure(approvalsRespCh chan *bot.ApprovalResponse, botMessagesChannel chan *bot.BotMessage) bool {
botToken := os.Getenv(constants.EnvSlackBotToken)
if !strings.HasPrefix(botToken, "xoxb-") {
log.Infof("bot.slack.Configure(): %s must have the prefix \"xoxb-\", skip bot configuration.", constants.EnvSlackBotToken)
return false
}
appToken := os.Getenv(constants.EnvSlackAppToken)
if !strings.HasPrefix(appToken, "xapp-") {
log.Infof("bot.slack.Configure(): %s must have the previf \"xapp-\".", constants.EnvSlackAppToken)
return false
}
botName, botNameConfigured := os.LookupEnv(constants.EnvSlackBotName)
if !botNameConfigured {
botName = "keel"
}
b.name = botName
channel, channelConfigured := os.LookupEnv(constants.EnvSlackApprovalsChannel)
if !channelConfigured {
channel = "general"
}
b.approvalsChannel = strings.TrimPrefix(channel, "#")
log.Debugf("Configuring slack with approval channel '%s' and bot '%s'", b.approvalsChannel, b.name)
debug, _ := strconv.ParseBool(os.Getenv("DEBUG"))
api := slack.New(
botToken,
slack.OptionDebug(debug),
slack.OptionAppLevelToken(appToken),
)
client := socketmode.New(
api,
socketmode.OptionDebug(debug),
)
b.slackSocket = client
b.approvalsRespCh = approvalsRespCh
b.botMessagesChannel = botMessagesChannel
return true
}
// Start - start bot
func (b *Bot) Start(ctx context.Context) error {
// setting root context
b.ctx = ctx
users, err := b.slackSocket.GetUsers()
if err != nil {
return err
}
b.users = map[string]string{}
// -- retrieve the bot user identifier from the bot name
var foundBots []string
for _, user := range users {
if user.IsBot {
foundBots = append(foundBots, user.Name)
if user.Name == b.name {
b.id = user.ID
break
}
}
}
if b.id == "" {
return errors.New("could not find bot in the list of names, check if the bot is called \"" + b.name + "\", found bots: " + strings.Join(foundBots[:], ", "))
}
// -- mentions and direct messages start with this message prefix. It is used from trimming the messages
b.msgPrefix = strings.ToLower("<@" + b.id + ">")
// -- retrieve the channel identifier from the approval channel name
b.approvalChannelId, err = b.findChannelId(b.approvalsChannel)
if err != nil {
return err
}
go b.listenForSocketEvents()
return nil
}
func (b *Bot) findChannelId(channelName string) (string, error) {
var channelId string
var cursor string
// -- while the channel is not found, fetch pages
for channelId == "" {
channels, nextCursor, err := b.slackSocket.GetConversationsForUser(&slack.GetConversationsForUserParameters{ExcludeArchived: true, Cursor: cursor})
if err != nil {
return "", err
}
for _, channel := range channels {
if channel.Name == channelName {
channelId = channel.ID
break
}
}
// -- channel not found on this page, check if there are more pages
if nextCursor == "" {
break
}
// -- continue to the next page
cursor = nextCursor
}
if channelId == "" {
return "", errors.New("Unable to retrieve the channel named \"" + channelName + "\". Check that the bot is invited to that channel and define the proper scope in the Slack app settings.")
} else {
return channelId, nil
}
}
func (b *Bot) listenForSocketEvents() error {
go func() {
for evt := range b.slackSocket.Events {
switch evt.Type {
case socketmode.EventTypeConnecting:
log.Info("Connecting to Slack with Socket Mode...")
case socketmode.EventTypeConnectionError:
if "missing_scope" == evt.Data {
log.Error("The application token is missing scopes, verify to provide an application token with the scope 'connections:write'", evt.Data)
} else {
log.Error("Connection failed. Retrying later... ", evt.Data)
}
case socketmode.EventTypeConnected:
log.Info("Connected to Slack with Socket Mode.")
case socketmode.EventTypeInvalidAuth:
log.Error("Invalid authentication parameter provided.", evt.Data)
case socketmode.EventTypeDisconnect:
log.Info("Disconnected from Slack socket.")
case socketmode.EventTypeIncomingError:
log.Error("An error occurred while processing an incoming event.", evt.Data)
case socketmode.EventTypeErrorBadMessage:
log.Error("Bad message error.", evt.Data)
case socketmode.EventTypeErrorWriteFailed:
log.Error("Error while responding to a message.", evt.Data)
case socketmode.EventTypeSlashCommand:
// ignore slash commands
case socketmode.EventTypeEventsAPI:
// The bot can receive mention events only when the bot has the Event Subscriptions enabled
// AND has a subscription to "app_mention" events
eventsAPIEvent, isEventApiEvent := evt.Data.(slackevents.EventsAPIEvent)
if !isEventApiEvent {
continue
}
innerEvent := eventsAPIEvent.InnerEvent
mentionEvent, isAppMentionEvent := innerEvent.Data.(*slackevents.AppMentionEvent)
if isAppMentionEvent && eventsAPIEvent.Type == slackevents.CallbackEvent {
// -- the bot was mentioned in a message, try to process the command
b.handleMentionEvent(mentionEvent)
b.slackSocket.Ack(*evt.Request)
}
case socketmode.EventTypeInteractive:
callback, isInteractionCallback := evt.Data.(slack.InteractionCallback)
if !isInteractionCallback {
log.Debugf("Ignoring Event %+v\n", evt)
continue
}
if callback.Type == slack.InteractionTypeBlockActions {
if (len(callback.ActionCallback.BlockActions)) == 0 {
log.Error("No block actions found")
continue
}
// callback.ResponseURL
blockAction := callback.ActionCallback.BlockActions[0]
b.handleAction(callback.User.ID, blockAction)
b.slackSocket.Ack(*evt.Request)
}
}
}
}()
b.slackSocket.Run()
return fmt.Errorf("No more events?")
}
// handleMentionEvent - Handle a mention event. The bot will only receive its own mention event. No need to check that the message is for him.
func (b *Bot) handleMentionEvent(event *slackevents.AppMentionEvent) {
if event.BotID != "" || event.User == "" {
log.WithFields(log.Fields{
"event_bot_ID": event.BotID,
"event_user": event.User,
"msg": event.Text,
}).Debug("handleMessage: ignoring message")
return
}
// -- clean the text message to have only the action (approve or reject) followed by the resource identifier
// -- (e.g. approve k8s/project/repo:1.2.3)
eventText := strings.Trim(strings.ToLower(event.Text), " \n\r")
eventText = b.trimBotName(eventText)
// -- first, try to handle the message as an approval response
approval, isAnApprovalResponse := bot.IsApproval(event.User, eventText)
if isAnApprovalResponse && b.isEventFromApprovalsChannel(event) {
// -- the message is processed by bot\approvals.go in ProcessApprovalResponses
b.approvalsRespCh <- approval
return
} else if isAnApprovalResponse {
log.WithFields(log.Fields{
"received_on": event.Channel,
"approvals_chan": b.approvalsChannel,
}).Warnf("The message was not received in the approval channel: %s", event.Channel)
b.Respond(fmt.Sprintf("Please use approvals channel '%s'", b.approvalsChannel), event.Channel)
return
}
// -- the message is not an approval response, try to handle the message as a generic bot command
b.botMessagesChannel <- &bot.BotMessage{
Message: eventText,
User: event.User,
Channel: event.Channel,
Name: "slack",
}
}
func (b *Bot) trimBotName(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
}
// isEventFromApprovalsChannel - checking if message was received in approvals channel
func (b *Bot) isEventFromApprovalsChannel(event *slackevents.AppMentionEvent) bool {
if b.approvalChannelId == event.Channel {
return true
} else {
log.Debug("Message was not received on the approvals channel, ignoring")
return false
}
}
// handleAction - Handle an action performed by using the slack block action feature.
// The bot will only receive events coming from its own action blocks. Block action can only be used to approve
// or reject an approval request (other commands should be managed by user bot mentions).
func (b *Bot) handleAction(username string, blockAction *slack.BlockAction) {
eventText := fmt.Sprintf("%s %s", blockAction.ActionID, blockAction.Value)
approval, ok := bot.IsApproval(username, eventText)
if !ok {
// -- only react to approval requests (approve or reject actions)
log.WithFields(log.Fields{
"action_user": username,
"action_id": blockAction.ActionID,
"action_value": blockAction.Value,
}).Debug("handleAction: ignoring action, clicked on unknown button")
return
}
b.approvalsRespCh <- approval
}
// postApprovalMessageBlock - effectively post a message to the approval channel
func (b *Bot) postApprovalMessageBlock(approvalId string, blocks slack.Blocks) error {
channelID := b.approvalsChannel
_, _, err := b.slackSocket.PostMessage(
channelID,
slack.MsgOptionBlocks(blocks.BlockSet...),
createApprovalMetadata(approvalId),
)
return err
}
// Respond - This method sent the text message to the provided channel
func (b *Bot) Respond(text string, channel string) {
// if message is short, replying directly via socket
if len(text) < 3000 {
b.slackSocket.SendMessage(channel, slack.MsgOptionText(formatAsSnippet(text), true))
return
}
// longer messages are getting uploaded as files
f := slack.FileUploadParameters{
Filename: "keel response",
Content: text,
Filetype: "text",
Channels: []string{channel},
}
_, err := b.slackSocket.UploadFile(f)
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Error("Respond: failed to send message")
}
}
// upsertApprovalMessage - update the approval message that was sent for the given resource identifier (deployment/default/wd:0.0.15).
// if the message is not found in the approval channel it will be created. That way even it the message is deleted,
// we will see the approval status
func (b *Bot) upsertApprovalMessage(approvalId string, blocks slack.Blocks) {
// Retrieve the message history
historyParams := &slack.GetConversationHistoryParameters{
ChannelID: b.approvalChannelId,
Limit: 250,
IncludeAllMetadata: true,
}
history, err := b.slackSocket.GetConversationHistory(historyParams)
if err != nil {
log.Debugf("Unable to get the conversation history to edit the message, post new one: %v", err)
b.postApprovalMessageBlock(approvalId, blocks)
}
// Find the message to update; the channel id and the message timestamp is the identifier of a message for slack
var messageTs string
for _, message := range history.Messages {
if isMessageOfApprovalRequest(message, approvalId) {
messageTs = message.Timestamp
break // Found the message
}
}
if messageTs == "" {
log.Debug("Unable to find the approval message for the identifier. Post a new message instead")
b.postApprovalMessageBlock(approvalId, blocks)
return
} else {
b.slackSocket.UpdateMessage(
b.approvalChannelId,
messageTs,
slack.MsgOptionBlocks(blocks.BlockSet...),
slack.MsgOptionAsUser(true),
createApprovalMetadata(approvalId),
)
}
}
// isMessageOfApprovalRequest - Check whether the given message is the approval message sent for the given approval identifier.
// Helps to identify the interactive message corresponding to the approval request in order to update the latest status of the approval.
// returns true if it is, false otherwise.
func isMessageOfApprovalRequest(message slack.Message, approvalId string) bool {
if message.Metadata.EventType != "approval" {
return false
}
if message.Metadata.EventPayload == nil {
return false
}
approvalID, ok := message.Metadata.EventPayload["approval_id"].(string)
if !ok {
return false
}
return approvalID == approvalId
}
// createApprovalMetadata - create message metadata, the metadata includes the approval identifier.
// That way, it is possible to identify clearly the approval message for a given approval request when looking into the
// history.
func createApprovalMetadata(approvalId string) slack.MsgOption {
return slack.MsgOptionMetadata(
slack.SlackMetadata{
EventType: "approval",
EventPayload: map[string]interface{}{
"approval_id": approvalId,
},
},
)
}
func formatAsSnippet(response string) string {
return "```" + response + "```"
}