430 lines
13 KiB
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 + "```"
|
|
}
|