Fix slack bot to use the new socket API.
The bot can now send interactive message for approval requests as well as react to bot mention event. The bot will try to update the interactive message when possible.pull/764/head
parent
a6281726ef
commit
e44b27a563
|
@ -2,6 +2,10 @@ package slack
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/keel-hq/keel/bot"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/keel-hq/keel/types"
|
||||
"github.com/slack-go/slack"
|
||||
|
@ -9,127 +13,164 @@ import (
|
|||
|
||||
// Request - request approval
|
||||
func (b *Bot) RequestApproval(req *types.Approval) error {
|
||||
return b.postMessage(
|
||||
"Approval required",
|
||||
req.Message,
|
||||
types.LevelSuccess.Color(),
|
||||
[]slack.AttachmentField{
|
||||
{
|
||||
Title: "Approval required!",
|
||||
Value: req.Message + "\n" + fmt.Sprintf("To vote for change type '%s approve %s' to reject it: '%s reject %s'.", b.name, req.Identifier, b.name, req.Identifier),
|
||||
Short: false,
|
||||
},
|
||||
{
|
||||
Title: "Votes",
|
||||
Value: fmt.Sprintf("%d/%d", req.VotesReceived, req.VotesRequired),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Delta",
|
||||
Value: req.Delta(),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Identifier",
|
||||
Value: req.Identifier,
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Provider",
|
||||
Value: req.Provider.String(),
|
||||
Short: true,
|
||||
},
|
||||
})
|
||||
return b.postApprovalMessageBlock(
|
||||
req.ID,
|
||||
createBlockMessage("Approval required! :mega:", b.name, req),
|
||||
)
|
||||
}
|
||||
|
||||
func (b *Bot) ReplyToApproval(approval *types.Approval) error {
|
||||
var title string
|
||||
switch approval.Status() {
|
||||
case types.ApprovalStatusPending:
|
||||
b.postMessage(
|
||||
"Vote received",
|
||||
"All approvals received, thanks for voting!",
|
||||
types.LevelInfo.Color(),
|
||||
[]slack.AttachmentField{
|
||||
{
|
||||
Title: "vote received!",
|
||||
Value: "Waiting for remaining votes.",
|
||||
Short: false,
|
||||
},
|
||||
{
|
||||
Title: "Votes",
|
||||
Value: fmt.Sprintf("%d/%d", approval.VotesReceived, approval.VotesRequired),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Delta",
|
||||
Value: approval.Delta(),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Identifier",
|
||||
Value: approval.Identifier,
|
||||
Short: true,
|
||||
},
|
||||
})
|
||||
title = "Approval required! :mega:"
|
||||
case types.ApprovalStatusRejected:
|
||||
b.postMessage(
|
||||
"Change rejected",
|
||||
"Change was rejected",
|
||||
types.LevelWarn.Color(),
|
||||
[]slack.AttachmentField{
|
||||
{
|
||||
Title: "change rejected",
|
||||
Value: "Change was rejected.",
|
||||
Short: false,
|
||||
},
|
||||
{
|
||||
Title: "Status",
|
||||
Value: approval.Status().String(),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Votes",
|
||||
Value: fmt.Sprintf("%d/%d", approval.VotesReceived, approval.VotesRequired),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Delta",
|
||||
Value: approval.Delta(),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Identifier",
|
||||
Value: approval.Identifier,
|
||||
Short: true,
|
||||
},
|
||||
})
|
||||
title = "Change rejected! :negative_squared_cross_mark:"
|
||||
case types.ApprovalStatusApproved:
|
||||
b.postMessage(
|
||||
"approval received",
|
||||
"All approvals received, thanks for voting!",
|
||||
types.LevelSuccess.Color(),
|
||||
[]slack.AttachmentField{
|
||||
{
|
||||
Title: "update approved!",
|
||||
Value: "All approvals received, thanks for voting!",
|
||||
Short: false,
|
||||
},
|
||||
{
|
||||
Title: "Votes",
|
||||
Value: fmt.Sprintf("%d/%d", approval.VotesReceived, approval.VotesRequired),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Delta",
|
||||
Value: approval.Delta(),
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Identifier",
|
||||
Value: approval.Identifier,
|
||||
Short: true,
|
||||
},
|
||||
})
|
||||
title = "Change approved! :tada:"
|
||||
}
|
||||
|
||||
b.upsertApprovalMessage(approval.ID, createBlockMessage(title, b.name, approval))
|
||||
return nil
|
||||
}
|
||||
|
||||
func createBlockMessage(title string, botName string, req *types.Approval) slack.Blocks {
|
||||
if req.Expired() {
|
||||
title = title + " (Expired)"
|
||||
}
|
||||
|
||||
headerText := slack.NewTextBlockObject(
|
||||
"plain_text",
|
||||
title,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
headerSection := slack.NewHeaderBlock(headerText)
|
||||
|
||||
messageSection := slack.NewTextBlockObject(
|
||||
"mrkdwn",
|
||||
req.Message,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
messageBlock := slack.NewSectionBlock(messageSection, nil, nil)
|
||||
|
||||
votesField := slack.NewTextBlockObject(
|
||||
"mrkdwn",
|
||||
fmt.Sprintf("*Votes:*\n%d/%d", req.VotesReceived, req.VotesRequired),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
deltaField := slack.NewTextBlockObject(
|
||||
"mrkdwn",
|
||||
"*Delta:*\n"+req.Delta(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
leftDetailSection := slack.NewSectionBlock(
|
||||
nil,
|
||||
[]*slack.TextBlockObject{
|
||||
votesField,
|
||||
deltaField,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
identifierField := slack.NewTextBlockObject(
|
||||
"mrkdwn",
|
||||
"*Identifier:*\n"+req.Identifier,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
providerField := slack.NewTextBlockObject(
|
||||
"mrkdwn",
|
||||
"*Provider:*\n"+req.Provider.String(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
rightDetailSection := slack.NewSectionBlock(nil, []*slack.TextBlockObject{identifierField, providerField}, nil)
|
||||
|
||||
commands := bot.BotEventTextToResponse["help"]
|
||||
var commandTexts []slack.MixedElement
|
||||
|
||||
for i, cmd := range commands {
|
||||
// -- avoid adding first line in commands which is the title.
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
cmd = addBotMentionToCommand(cmd, botName)
|
||||
commandTexts = append(commandTexts, slack.NewTextBlockObject("mrkdwn", cmd, false, false))
|
||||
}
|
||||
commandsBlock := slack.NewContextBlock("", commandTexts...)
|
||||
header := commands[0]
|
||||
|
||||
blocks := []slack.Block{
|
||||
headerSection,
|
||||
messageBlock,
|
||||
leftDetailSection,
|
||||
rightDetailSection,
|
||||
slack.NewDividerBlock(),
|
||||
slack.NewContextBlock("", slack.NewTextBlockObject("mrkdwn", header, false, false)),
|
||||
commandsBlock,
|
||||
}
|
||||
|
||||
if req.VotesReceived < req.VotesRequired && !req.Expired() && !req.Rejected {
|
||||
approveButton := slack.NewButtonBlockElement(
|
||||
bot.ApprovalResponseKeyword,
|
||||
req.Identifier,
|
||||
slack.NewTextBlockObject(
|
||||
"plain_text",
|
||||
"Approve",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
)
|
||||
approveButton.Style = slack.StylePrimary
|
||||
|
||||
rejectButton := slack.NewButtonBlockElement(
|
||||
bot.RejectResponseKeyword,
|
||||
req.Identifier,
|
||||
slack.NewTextBlockObject(
|
||||
"plain_text",
|
||||
"Reject",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
)
|
||||
rejectButton.Style = slack.StyleDanger
|
||||
|
||||
actionBlock := slack.NewActionBlock("", approveButton, rejectButton)
|
||||
|
||||
blocks = append(
|
||||
blocks,
|
||||
slack.NewDividerBlock(),
|
||||
actionBlock,
|
||||
)
|
||||
}
|
||||
return slack.Blocks{
|
||||
BlockSet: blocks,
|
||||
}
|
||||
}
|
||||
|
||||
func addBotMentionToCommand(command string, botName string) string {
|
||||
// -- retrieve the first letter of the command in order to insert bot mention
|
||||
firstLetterPos := -1
|
||||
for i, r := range command {
|
||||
if unicode.IsLetter(r) {
|
||||
firstLetterPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if firstLetterPos < 0 {
|
||||
log.Debugf("Unable to find the first letter of the command '%s', let the command without the bot mention.", command)
|
||||
return command
|
||||
}
|
||||
|
||||
return strings.Replace(
|
||||
command[:firstLetterPos]+fmt.Sprintf("@%s ", botName)+command[firstLetterPos:],
|
||||
"\"",
|
||||
"`",
|
||||
-1,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,19 +2,16 @@ package slack
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
"time"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
|
||||
"github.com/keel-hq/keel/bot"
|
||||
"github.com/keel-hq/keel/constants"
|
||||
"github.com/keel-hq/keel/version"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
@ -28,10 +25,13 @@ type Bot struct {
|
|||
|
||||
msgPrefix string
|
||||
|
||||
slackClient *slack.Client
|
||||
slackRTM *slack.RTM
|
||||
slackSocket *socketmode.Client
|
||||
|
||||
approvalsChannel string // slack approvals channel name
|
||||
// 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
|
||||
|
@ -43,31 +43,51 @@ func init() {
|
|||
}
|
||||
|
||||
func (b *Bot) Configure(approvalsRespCh chan *bot.ApprovalResponse, botMessagesChannel chan *bot.BotMessage) bool {
|
||||
if os.Getenv(constants.EnvSlackToken) != "" {
|
||||
botToken := os.Getenv(constants.EnvSlackBotToken)
|
||||
|
||||
b.name = "keel"
|
||||
if botName := os.Getenv(constants.EnvSlackBotName); botName != "" {
|
||||
b.name = botName
|
||||
}
|
||||
|
||||
token := os.Getenv(constants.EnvSlackToken)
|
||||
|
||||
debug, _ := strconv.ParseBool(os.Getenv("DEBUG"))
|
||||
client := slack.New(token, slack.OptionDebug(debug))
|
||||
|
||||
b.approvalsChannel = "general"
|
||||
if channel := os.Getenv(constants.EnvSlackApprovalsChannel); channel != "" {
|
||||
b.approvalsChannel = strings.TrimPrefix(channel, "#")
|
||||
}
|
||||
|
||||
b.slackClient = client
|
||||
b.approvalsRespCh = approvalsRespCh
|
||||
b.botMessagesChannel = botMessagesChannel
|
||||
|
||||
return true
|
||||
if !strings.HasPrefix(botToken, "xoxb-") {
|
||||
log.Infof("bot.slack.Configure(): %s must have the prefix \"xoxb-\", skip bot configuration.", constants.EnvSlackBotToken)
|
||||
return false
|
||||
}
|
||||
log.Info("bot.slack.Configure(): Slack approval bot is not configured")
|
||||
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
|
||||
|
@ -75,154 +95,179 @@ func (b *Bot) Start(ctx context.Context) error {
|
|||
// setting root context
|
||||
b.ctx = ctx
|
||||
|
||||
users, err := b.slackClient.GetUsers()
|
||||
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 {
|
||||
switch user.Name {
|
||||
case b.name:
|
||||
if user.IsBot {
|
||||
if user.IsBot {
|
||||
foundBots = append(foundBots, user.Name)
|
||||
if user.Name == b.name {
|
||||
b.id = user.ID
|
||||
break
|
||||
}
|
||||
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 + "\" ")
|
||||
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 + ">")
|
||||
|
||||
go b.startInternal()
|
||||
// -- 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) startInternal() error {
|
||||
b.slackRTM = b.slackClient.NewRTM()
|
||||
func (b *Bot) findChannelId(channelName string) (string, error) {
|
||||
var channelId string
|
||||
var cursor string
|
||||
|
||||
go b.slackRTM.ManageConnection()
|
||||
|
||||
for msg := range b.slackRTM.IncomingEvents {
|
||||
switch ev := msg.Data.(type) {
|
||||
case *slack.HelloEvent:
|
||||
// Ignore hello
|
||||
case *slack.ConnectedEvent:
|
||||
// nothing to do
|
||||
case *slack.MessageEvent:
|
||||
b.handleMessage(ev)
|
||||
case *slack.PresenceChangeEvent:
|
||||
// nothing to do
|
||||
case *slack.RTMError:
|
||||
log.Errorf("Error: %s", ev.Error())
|
||||
case *slack.InvalidAuthEvent:
|
||||
log.Error("Invalid credentials")
|
||||
return fmt.Errorf("invalid credentials")
|
||||
|
||||
default:
|
||||
|
||||
// Ignore other events..
|
||||
// fmt.Printf("Unexpected: %v\n", msg.Data)
|
||||
// -- 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?")
|
||||
}
|
||||
|
||||
func (b *Bot) postMessage(title, message, color string, fields []slack.AttachmentField) error {
|
||||
params := slack.NewPostMessageParameters()
|
||||
params.Username = b.name
|
||||
params.IconURL = b.getBotUserIconURL()
|
||||
|
||||
attachments := []slack.Attachment{
|
||||
{
|
||||
Fallback: message,
|
||||
Color: color,
|
||||
Fields: fields,
|
||||
Footer: fmt.Sprintf("https://keel.sh %s", version.GetKeelVersion().Version),
|
||||
Ts: json.Number(strconv.Itoa(int(time.Now().Unix()))),
|
||||
},
|
||||
}
|
||||
|
||||
var mgsOpts []slack.MsgOption
|
||||
|
||||
mgsOpts = append(mgsOpts, slack.MsgOptionPostMessageParameters(params))
|
||||
mgsOpts = append(mgsOpts, slack.MsgOptionAttachments(attachments...))
|
||||
|
||||
_, _, err := b.slackClient.PostMessage(b.approvalsChannel, mgsOpts...)
|
||||
if err != nil {
|
||||
// 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{
|
||||
"error": err,
|
||||
"approvals_channel": b.approvalsChannel,
|
||||
}).Error("bot.postMessage: failed to send message")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// checking if message was received in approvals channel
|
||||
func (b *Bot) isApprovalsChannel(event *slack.MessageEvent) bool {
|
||||
|
||||
channel, err := b.slackClient.GetConversationInfo(&slack.GetConversationInfoInput{ChannelID: event.Channel})
|
||||
if err != nil {
|
||||
if channel != (&slack.Channel{}) && channel != nil {
|
||||
if channel.GroupConversation.Name == b.approvalsChannel &&
|
||||
channel.GroupConversation.Conversation.IsPrivate {
|
||||
return true
|
||||
} else {
|
||||
log.Errorf("couldn't find amongst private conversations: %s", err)
|
||||
}
|
||||
}
|
||||
log.WithError(err).Errorf("channel with ID %s could not be retrieved", event.Channel)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Debugf("checking if approvals channel: %s==%s", channel.GroupConversation.Name, b.approvalsChannel)
|
||||
if channel.GroupConversation.Name == b.approvalsChannel {
|
||||
return true
|
||||
}
|
||||
|
||||
log.Debugf("message was received not on approvals channel (%s)", channel.GroupConversation.Name)
|
||||
|
||||
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,
|
||||
"msg": event.Text,
|
||||
"event_subtype": event.SubType,
|
||||
"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")
|
||||
|
||||
if !b.isBotMessage(event, eventText) {
|
||||
return
|
||||
}
|
||||
eventText = b.trimBotName(eventText)
|
||||
|
||||
eventText = b.trimBot(eventText)
|
||||
// -- first, try to handle the message as an approval response
|
||||
approval, isAnApprovalResponse := bot.IsApproval(event.User, eventText)
|
||||
|
||||
approval, ok := bot.IsApproval(event.User, eventText)
|
||||
// only accepting approvals from approvals channel
|
||||
if ok && b.isApprovalsChannel(event) {
|
||||
if isAnApprovalResponse && b.isEventFromApprovalsChannel(event) {
|
||||
// -- the message is processed by bot\approvals.go in ProcessApprovalResponses
|
||||
b.approvalsRespCh <- approval
|
||||
return
|
||||
} else if ok {
|
||||
} else if isAnApprovalResponse {
|
||||
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)
|
||||
}).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,
|
||||
|
@ -231,49 +276,7 @@ func (b *Bot) handleMessage(event *slack.MessageEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
func (b *Bot) Respond(text string, channel string) {
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) isBotMessage(event *slack.MessageEvent, eventText string) bool {
|
||||
prefixes := []string{
|
||||
b.msgPrefix,
|
||||
b.name,
|
||||
// "kel",
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
|
@ -281,18 +284,146 @@ func (b *Bot) trimBot(msg string) string {
|
|||
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 + "```"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -108,12 +108,12 @@ func newTestingUtils() (*sql.SQLStore, func()) {
|
|||
|
||||
func TestBotRequest(t *testing.T) {
|
||||
|
||||
os.Setenv(constants.EnvSlackToken, "")
|
||||
os.Setenv(constants.EnvSlackBotToken, "")
|
||||
|
||||
f8s := &testutil.FakeK8sImplementer{}
|
||||
fi := &fakeSlackImplementer{}
|
||||
|
||||
token := os.Getenv(constants.EnvSlackToken)
|
||||
token := os.Getenv(constants.EnvSlackBotToken)
|
||||
if token == "" {
|
||||
t.Skip()
|
||||
}
|
||||
|
@ -155,12 +155,12 @@ func TestBotRequest(t *testing.T) {
|
|||
|
||||
func TestProcessApprovedResponse(t *testing.T) {
|
||||
|
||||
os.Setenv(constants.EnvSlackToken, "")
|
||||
os.Setenv(constants.EnvSlackBotToken, "")
|
||||
|
||||
f8s := &testutil.FakeK8sImplementer{}
|
||||
fi := &fakeSlackImplementer{}
|
||||
|
||||
token := os.Getenv(constants.EnvSlackToken)
|
||||
token := os.Getenv(constants.EnvSlackBotToken)
|
||||
if token == "" {
|
||||
t.Skip()
|
||||
}
|
||||
|
@ -202,12 +202,12 @@ func TestProcessApprovedResponse(t *testing.T) {
|
|||
|
||||
func TestProcessApprovalReply(t *testing.T) {
|
||||
|
||||
os.Setenv(constants.EnvSlackToken, "")
|
||||
os.Setenv(constants.EnvSlackBotToken, "")
|
||||
|
||||
f8s := &testutil.FakeK8sImplementer{}
|
||||
fi := &fakeSlackImplementer{}
|
||||
|
||||
token := os.Getenv(constants.EnvSlackToken)
|
||||
token := os.Getenv(constants.EnvSlackBotToken)
|
||||
if token == "" {
|
||||
t.Skip()
|
||||
}
|
||||
|
@ -273,12 +273,12 @@ func TestProcessApprovalReply(t *testing.T) {
|
|||
|
||||
func TestProcessRejectedReply(t *testing.T) {
|
||||
|
||||
os.Setenv(constants.EnvSlackToken, "")
|
||||
os.Setenv(constants.EnvSlackBotToken, "")
|
||||
|
||||
f8s := &testutil.FakeK8sImplementer{}
|
||||
fi := &fakeSlackImplementer{}
|
||||
|
||||
token := os.Getenv(constants.EnvSlackToken)
|
||||
token := os.Getenv(constants.EnvSlackBotToken)
|
||||
if token == "" {
|
||||
t.Skip()
|
||||
}
|
||||
|
|
|
@ -11,7 +11,8 @@ const WebhookEndpointEnv = "WEBHOOK_ENDPOINT"
|
|||
|
||||
// slack bot/token
|
||||
const (
|
||||
EnvSlackToken = "SLACK_TOKEN"
|
||||
EnvSlackBotToken = "SLACK_BOT_TOKEN"
|
||||
EnvSlackAppToken = "SLACK_APP_TOKEN"
|
||||
EnvSlackBotName = "SLACK_BOT_NAME"
|
||||
EnvSlackChannels = "SLACK_CHANNELS"
|
||||
EnvSlackApprovalsChannel = "SLACK_APPROVALS_CHANNEL"
|
||||
|
|
|
@ -33,8 +33,8 @@ func init() {
|
|||
func (s *sender) Configure(config *notification.Config) (bool, error) {
|
||||
var token string
|
||||
// Get configuration
|
||||
if os.Getenv(constants.EnvSlackToken) != "" {
|
||||
token = os.Getenv(constants.EnvSlackToken)
|
||||
if os.Getenv(constants.EnvSlackBotToken) != "" {
|
||||
token = os.Getenv(constants.EnvSlackBotToken)
|
||||
} else {
|
||||
return false, nil
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue