package bot import ( "context" "fmt" "strings" "sync" "github.com/keel-hq/keel/approvals" "github.com/keel-hq/keel/provider/kubernetes" "github.com/keel-hq/keel/types" log "github.com/sirupsen/logrus" ) const ( RemoveApprovalPrefix = "rm approval" ) var ( BotEventTextToResponse = map[string][]string{ "help": { `Here's a list of supported commands`, `- "get deployments" -> get a list of all deployments`, `- "get approvals" -> get a list of approvals`, `- "rm approval " -> remove approval`, `- "approve " -> approve update request`, `- "reject " -> reject update request`, // `- "get deployments all" -> get a list of all deployments`, // `- "describe deployment " -> get details for specified deployment`, }, } // static bot commands can be used straight away staticBotCommands = map[string]bool{ "get deployments": true, "get approvals": true, } // dynamic bot command prefixes have to be matched dynamicBotCommandPrefixes = []string{RemoveApprovalPrefix} ApprovalResponseKeyword = "approve" RejectResponseKeyword = "reject" ) type Bot interface { Configure(approvalsRespCh chan *ApprovalResponse, botMessagesChannel chan *BotMessage) bool Start(ctx context.Context) error Respond(text string, channel string) RequestApproval(req *types.Approval) error ReplyToApproval(approval *types.Approval) error } type teardown func() type BotMessageResponder func(response string, channel string) var ( botsM sync.RWMutex bots = make(map[string]Bot) teardowns = make(map[string]teardown) ) // BotMessage represents abstract container for any bot Message // add here more fields if you needed for a new bot implementation type BotMessage struct { Message string User string Name string Channel string } // ApprovalResponse - used to track approvals once vote begins type ApprovalResponse struct { User string Status types.ApprovalStatus Text string } // BotManager holds approvalsManager and k8sImplementer for every bot type BotManager struct { approvalsManager approvals.Manager k8sImplementer kubernetes.Implementer botMessagesChannel chan *BotMessage approvalsRespCh chan *ApprovalResponse } // RegisterBot makes a bot implementation available by the provided name. func RegisterBot(name string, b Bot) { if name == "" { panic("bot: could not register a BotFactory with an empty name") } if b == nil { panic("bot: could not register a nil Bot interface") } botsM.Lock() defer botsM.Unlock() if _, dup := bots[name]; dup { panic("bot: RegisterBot called twice for " + name) } log.WithFields(log.Fields{ "name": name, }).Info("bot: registered") bots[name] = b } // Run all implemented bots func Run(k8sImplementer kubernetes.Implementer, approvalsManager approvals.Manager) { bm := &BotManager{ approvalsManager: approvalsManager, k8sImplementer: k8sImplementer, approvalsRespCh: make(chan *ApprovalResponse), // don't add buffer to make it blocking botMessagesChannel: make(chan *BotMessage), } for botName, bot := range bots { configured := bot.Configure(bm.approvalsRespCh, bm.botMessagesChannel) if configured { bm.SetupBot(botName, bot) } else { log.Errorf("bot.Run(): can not get configuration for bot [%s]", botName) } } } func (bm *BotManager) SetupBot(botName string, bot Bot) { ctx, cancel := context.WithCancel(context.Background()) err := bot.Start(ctx) if err != nil { cancel() log.WithFields(log.Fields{ "error": err, }).Fatalf("main: failed to setup %s bot\n", botName) } else { // store cancelling context for each bot teardowns[botName] = func() { cancel() } go bm.ProcessBotMessages(ctx, bot.Respond) go bm.ProcessApprovalResponses(ctx, bot.ReplyToApproval) go bm.SubscribeForApprovals(ctx, bot.RequestApproval) } } func (bm *BotManager) ProcessBotMessages(ctx context.Context, respond BotMessageResponder) { for { select { case <-ctx.Done(): return case message := <-bm.botMessagesChannel: response := bm.handleBotMessage(message) if response != "" { respond(response, message.Channel) } } } } func Stop() { for botName, teardown := range teardowns { log.Infof("Teardown %s bot", botName) teardown() UnregisterBot(botName) } } func IsBotCommand(eventText string) bool { if staticBotCommands[eventText] { return true } for _, prefix := range dynamicBotCommandPrefixes { if strings.HasPrefix(eventText, prefix) { return true } } return false } func (bm *BotManager) handleCommand(eventText string) string { switch eventText { case "get deployments": log.Info("HandleCommand: getting deployments") return DeploymentsResponse(Filter{}, bm.k8sImplementer) case "get approvals": log.Info("HandleCommand: getting approvals") return ApprovalsResponse(bm.approvalsManager) } // handle dynamic commands if strings.HasPrefix(eventText, RemoveApprovalPrefix) { id := strings.TrimSpace(strings.TrimPrefix(eventText, RemoveApprovalPrefix)) return RemoveApprovalHandler(id, bm.approvalsManager) } log.Infof("bot.HandleCommand(): command [%s] not found", eventText) return "" } func (bm *BotManager) handleBotMessage(m *BotMessage) string { command := m.Message switch command { case "", "has joined the channel": return "" } if responseLines, ok := BotEventTextToResponse[command]; ok { return strings.Join(responseLines, "\n") } if IsBotCommand(command) { return bm.handleCommand(command) } log.WithFields(log.Fields{ "user": m.User, "bot": m.Name, "command": command, }).Debug("handleMessage: bot couldn't recognize command") return fmt.Sprintf("unknown command '%s'", command) } // UnregisterBot removes a Sender with a particular name from the list. func UnregisterBot(name string) { botsM.Lock() defer botsM.Unlock() delete(bots, name) }