diff --git a/bot/command.go b/bot/command.go index 32b81581..14904277 100644 --- a/bot/command.go +++ b/bot/command.go @@ -26,6 +26,11 @@ type Conditional interface { IsEnabled() bool } +// Runnable indicates that the command executes a go function +type Runnable interface { + RunAsync() +} + // HelpProvider can be provided by a command to add information within "help" command type HelpProvider interface { // GetHelp each command should provide information, like a description or examples diff --git a/bot/config/config.go b/bot/config/config.go index 7240665f..7e608c78 100644 --- a/bot/config/config.go +++ b/bot/config/config.go @@ -5,6 +5,7 @@ package config // Config contains the full config structure of this bot type Config struct { Slack Slack `mapstructure:"slack"` + Pool Pool `mapstructure:"pool"` Jenkins Jenkins `mapstructure:"jenkins"` Jira Jira `mapstructure:"jira"` StoragePath string `mapstructure:"storage_path"` diff --git a/bot/config/pool.go b/bot/config/pool.go new file mode 100644 index 00000000..c88afdd2 --- /dev/null +++ b/bot/config/pool.go @@ -0,0 +1,23 @@ +package config + +import "time" + +// Pool config contains the Resources of the Pool +type Pool struct { + LockDuration time.Duration + NotifyExpire time.Duration + Resources []*Resource +} + +// Resource config contains definitions about the +type Resource struct { + Name string + ExplicitLock bool + Addresses []string + Features []string +} + +// IsEnabled checks if there are resources in the pool +func (c *Pool) IsEnabled() bool { + return len(c.Resources) > 0 +} diff --git a/bot/fallback.go b/bot/fallback.go index 0f6acf06..1313832e 100644 --- a/bot/fallback.go +++ b/bot/fallback.go @@ -26,7 +26,7 @@ func (b *Bot) sendFallbackMessage(message msg.Message) { ), slack.NewActionBlock( "", - client.GetInteractionButton("Help!", "help"), + client.GetInteractionButton("help", "Help!", "help"), ), } b.slackClient.SendBlockMessage(message, blocks) diff --git a/bot/interaction_test.go b/bot/interaction_test.go index c989eeaa..06fd3f26 100644 --- a/bot/interaction_test.go +++ b/bot/interaction_test.go @@ -133,7 +133,7 @@ func TestInteraction(t *testing.T) { messageEvent.Channel = "D1234" messageEvent.User = "user1" - action := slack.NewActionBlock("", client.GetInteractionButton("my text", "dummy")) + action := slack.NewActionBlock("", client.GetInteractionButton("dummy", "my text", "dummy")) button := action.Elements.ElementSet[0].(*slack.ButtonBlockElement) actionID := button.Value assert.Equal(t, "dummy", actionID) @@ -196,7 +196,7 @@ func TestInteraction(t *testing.T) { func TestReplaceClickedButton(t *testing.T) { messageEvent := &slack.MessageEvent{} - action := slack.NewActionBlock("", client.GetInteractionButton("my text", "replay YEP", slack.StylePrimary)) + action := slack.NewActionBlock("", client.GetInteractionButton("reply", "my text", "replay YEP", slack.StylePrimary)) button := action.Elements.ElementSet[0].(*slack.ButtonBlockElement) actionID := button.Value assert.Equal(t, "replay YEP", actionID) @@ -210,7 +210,7 @@ func TestReplaceClickedButton(t *testing.T) { actual := replaceClickedButton((*slack.Message)(messageEvent), actionID, " (worked)") jsonString, err := json.Marshal(actual) - expected := `{"replace_original":false,"delete_original":false,"metadata":{"event_type":"","event_payload":null},"blocks":[{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"my text (worked)","emoji":true},"action_id":"id","style":"danger"}]}]}` + expected := `{"replace_original":false,"delete_original":false,"metadata":{"event_type":"","event_payload":null},"blocks":[{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"my text (worked)","emoji":true},"action_id":"reply","style":"danger"}]}]}` assert.Nil(t, err) assert.Equal(t, expected, string(jsonString)) diff --git a/bot/listener.go b/bot/listener.go index aa12a116..90a9e912 100644 --- a/bot/listener.go +++ b/bot/listener.go @@ -1,6 +1,7 @@ package bot import ( + "context" "os" "os/signal" "syscall" @@ -14,8 +15,19 @@ import ( "github.com/slack-go/slack/socketmode" ) +func (b *Bot) StartRunnables(ctx context.Context) { + for _, cmd := range b.commands.commands { + if runnable, ok := cmd.(Runnable); ok { + go runnable.RunAsync() + } + } +} + // Run is blocking method to handle new incoming events...from different sources func (b *Bot) Run(ctx *util.ServerContext) { + + b.StartRunnables(ctx) + // listen for old/deprecated RTM connection // https://api.slack.com/rtm var rtmChan chan slack.RTMEvent diff --git a/client/slack.go b/client/slack.go index 2549d6e7..29733044 100644 --- a/client/slack.go +++ b/client/slack.go @@ -104,6 +104,8 @@ type SlackClient interface { // SendEphemeralMessage sends a message just visible to the current user SendEphemeralMessage(ref msg.Ref, text string, options ...slack.MsgOption) + SendBlockMessageToUser(user string, blocks []slack.Block, options ...slack.MsgOption) string + // SendBlockMessage will send Slack Blocks/Sections to the target SendBlockMessage(ref msg.Ref, blocks []slack.Block, options ...slack.MsgOption) string @@ -234,6 +236,31 @@ func (s *Slack) SendToUser(user string, text string) { s.SendMessage(message, text) } +// SendBlockMessage will send Slack Blocks/Sections to the target +func (s *Slack) SendBlockMessageToUser(user string, blocks []slack.Block, options ...slack.MsgOption) string { + // check if a real username was passed -> we need the user-id here + userID, _ := GetUserIDAndName(user) + if userID == "" { + log.Errorf("Invalid user: %s", user) + return "" + } + + conversationOptions := &slack.OpenConversationParameters{ + Users: []string{userID}, + } + + channel, _, _, err := s.Client.OpenConversation(conversationOptions) + if err != nil { + log.WithError(err).Errorf("Cannot open channel") + return "" + } + + message := msg.Message{} + message.Channel = channel.ID + + return s.SendBlockMessage(message, blocks, options...) +} + // CanHandleInteractions checks if we have a slack connections which can inform us about events/interactions, like pressed buttons? func (s *Slack) CanHandleInteractions() bool { return s.config.CanHandleInteractions() @@ -326,14 +353,14 @@ func GetContextBlock(text string) *slack.ContextBlock { // GetInteractionButton generates a block "Button" which is able to execute the given command once // https://api.slack.com/reference/block-kit/blocks#actions -func GetInteractionButton(text string, command string, args ...slack.Style) *slack.ButtonBlockElement { +func GetInteractionButton(id, text, command string, args ...slack.Style) *slack.ButtonBlockElement { var style slack.Style if len(args) > 0 { style = args[0] } buttonText := slack.NewTextBlockObject("plain_text", text, true, false) - button := slack.NewButtonBlockElement("id", command, buttonText) + button := slack.NewButtonBlockElement(id, command, buttonText) button.Style = style return button diff --git a/command/add_button.go b/command/add_button.go index 555d9c02..e93d9528 100644 --- a/command/add_button.go +++ b/command/add_button.go @@ -26,7 +26,7 @@ func (c *addButtonCommand) addLink(match matcher.Result, message msg.Message) { command := match.GetString("command") blocks := []slack.Block{ - slack.NewActionBlock("", client.GetInteractionButton(name, command)), + slack.NewActionBlock("", client.GetInteractionButton("link", name, command)), } c.SendBlockMessage(message, blocks) diff --git a/command/add_button_test.go b/command/add_button_test.go index e674038c..49453799 100644 --- a/command/add_button_test.go +++ b/command/add_button_test.go @@ -22,7 +22,7 @@ func TestAddButton(t *testing.T) { message := msg.Message{} message.Text = `add button "test" "reply it works"` - expected := `[{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"test","emoji":true},"action_id":"id","value":"reply it works"}]}]` + expected := `[{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"test","emoji":true},"action_id":"link","value":"reply it works"}]}]` mocks.AssertSlackBlocks(t, slackClient, message, expected) diff --git a/command/commands.go b/command/commands.go index 99fcaa32..b841327b 100644 --- a/command/commands.go +++ b/command/commands.go @@ -11,6 +11,7 @@ import ( "github.com/innogames/slack-bot/v2/command/games" "github.com/innogames/slack-bot/v2/command/jenkins" "github.com/innogames/slack-bot/v2/command/jira" + "github.com/innogames/slack-bot/v2/command/pool" "github.com/innogames/slack-bot/v2/command/pullrequest" "github.com/innogames/slack-bot/v2/command/queue" "github.com/innogames/slack-bot/v2/command/variables" @@ -64,6 +65,9 @@ func GetCommands(slackClient client.SlackClient, cfg config.Config) *bot.Command // aws commands.Merge(aws.GetCommands(cfg.Aws, base)) + // pool + commands.Merge(pool.GetCommands(&cfg.Pool, base)) + return commands } diff --git a/command/delay.go b/command/delay.go index c76ee18f..6979053b 100644 --- a/command/delay.go +++ b/command/delay.go @@ -68,7 +68,7 @@ func (c *delayCommand) Delay(match matcher.Result, message msg.Message) { blocks, slack.NewActionBlock( "", - client.GetInteractionButton("Stop timer!", fmt.Sprintf("stop timer %d", stopNumber)), + client.GetInteractionButton("stop_timer", "Stop timer!", fmt.Sprintf("stop timer %d", stopNumber)), ), ) } diff --git a/command/delay_test.go b/command/delay_test.go index 8f71c8e5..7fc7d803 100644 --- a/command/delay_test.go +++ b/command/delay_test.go @@ -53,7 +53,7 @@ func TestDelay(t *testing.T) { message := msg.Message{} message.Text = "delay 20ms my command" - expected := "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"I queued the command `my command` for 20ms. Use `stop timer 0` to stop the timer\"}},{\"type\":\"actions\",\"elements\":[{\"type\":\"button\",\"text\":{\"type\":\"plain_text\",\"text\":\"Stop timer!\",\"emoji\":true},\"action_id\":\"id\",\"value\":\"stop timer 0\"}]}]" + expected := "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"I queued the command `my command` for 20ms. Use `stop timer 0` to stop the timer\"}},{\"type\":\"actions\",\"elements\":[{\"type\":\"button\",\"text\":{\"type\":\"plain_text\",\"text\":\"Stop timer!\",\"emoji\":true},\"action_id\":\"stop_timer\",\"value\":\"stop timer 0\"}]}]" mocks.AssertSlackBlocks(t, slackClient, message, expected) actual := command.Run(message) @@ -98,7 +98,7 @@ func TestDelay(t *testing.T) { message := msg.Message{} message.Text = "delay 20ms my command" - expected := "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"I queued the command `my command` for 20ms. Use `stop timer 0` to stop the timer\"}},{\"type\":\"actions\",\"elements\":[{\"type\":\"button\",\"text\":{\"type\":\"plain_text\",\"text\":\"Stop timer!\",\"emoji\":true},\"action_id\":\"id\",\"value\":\"stop timer 0\"}]}]" + expected := "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"I queued the command `my command` for 20ms. Use `stop timer 0` to stop the timer\"}},{\"type\":\"actions\",\"elements\":[{\"type\":\"button\",\"text\":{\"type\":\"plain_text\",\"text\":\"Stop timer!\",\"emoji\":true},\"action_id\":\"stop_timer\",\"value\":\"stop timer 0\"}]}]" mocks.AssertSlackBlocks(t, slackClient, message, expected) actual := command.Run(message) diff --git a/command/games/quiz.go b/command/games/quiz.go index 2832bcd1..0069133e 100644 --- a/command/games/quiz.go +++ b/command/games/quiz.go @@ -150,12 +150,12 @@ func (c *quizCommand) printCurrentQuestion(message msg.Message) { blocks := []slack.Block{ client.GetTextBlock(text), } - for _, answer := range question.Answers { + for i, answer := range question.Answers { blocks = append( blocks, slack.NewActionBlock( "", - client.GetInteractionButton(answer, fmt.Sprintf("answer %s", answer)), + client.GetInteractionButton(fmt.Sprintf("answer_%d", i), answer, fmt.Sprintf("answer %s", answer)), ), ) } diff --git a/command/games/quiz_test.go b/command/games/quiz_test.go index aa0875f4..a5eade51 100644 --- a/command/games/quiz_test.go +++ b/command/games/quiz_test.go @@ -38,10 +38,10 @@ func TestQuiz(t *testing.T) { expected := `[` + `{"type":"section","text":{"type":"mrkdwn","text":"Next question (#1) is of *\"hard\" difficulty* from the category: \"*Entertainment: Video Games*\"\nAccording to Toby Fox, what was the method to creating the initial tune for Megalovania?\n"}},` + - `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Using a Composer Software","emoji":true},"action_id":"id","value":"answer Using a Composer Software"}]},` + - `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Listened to birds at the park","emoji":true},"action_id":"id","value":"answer Listened to birds at the park"}]},` + - `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Singing into a Microphone","emoji":true},"action_id":"id","value":"answer Singing into a Microphone"}]},` + - `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Playing a Piano","emoji":true},"action_id":"id","value":"answer Playing a Piano"}]}` + + `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Using a Composer Software","emoji":true},"action_id":"answer_0","value":"answer Using a Composer Software"}]},` + + `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Listened to birds at the park","emoji":true},"action_id":"answer_1","value":"answer Listened to birds at the park"}]},` + + `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Singing into a Microphone","emoji":true},"action_id":"answer_2","value":"answer Singing into a Microphone"}]},` + + `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"Playing a Piano","emoji":true},"action_id":"answer_3","value":"answer Playing a Piano"}]}` + `]` mocks.AssertSlackBlocks(t, slackClient, message, expected) @@ -61,8 +61,8 @@ func TestQuiz(t *testing.T) { expected = `[` + `{"type":"section","text":{"type":"mrkdwn","text":"Next question (#2) is of *\"easy\" difficulty* from the category: \"*Math*\"\nWhat's 1+4?\n"}},` + - `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"5","emoji":true},"action_id":"id","value":"answer 5"}]},` + - `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"6","emoji":true},"action_id":"id","value":"answer 6"}]}` + + `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"5","emoji":true},"action_id":"answer_0","value":"answer 5"}]},` + + `{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"6","emoji":true},"action_id":"answer_1","value":"answer 6"}]}` + `]` mocks.AssertSlackBlocks(t, slackClient, message, expected) diff --git a/command/pool/commands.go b/command/pool/commands.go new file mode 100644 index 00000000..7097c151 --- /dev/null +++ b/command/pool/commands.go @@ -0,0 +1,29 @@ +package pool + +import ( + "github.com/innogames/slack-bot/v2/bot" + "github.com/innogames/slack-bot/v2/bot/config" + "github.com/innogames/slack-bot/v2/client" +) + +// GetCommands will return a list of available Pool commands...if the config is set! +func GetCommands(cfg *config.Pool, slackClient client.SlackClient) bot.Commands { + var commands bot.Commands + + if !cfg.IsEnabled() { + return commands + } + + p := GetNewPool(cfg) + + commands.AddCommand( + newPoolCommands(slackClient, cfg, p), + ) + + return commands +} + +var category = bot.Category{ + Name: "Pool", + Description: "Lock/Unlock/Manage Resources of a Pool", +} diff --git a/command/pool/pool.go b/command/pool/pool.go new file mode 100644 index 00000000..75b8a9cf --- /dev/null +++ b/command/pool/pool.go @@ -0,0 +1,231 @@ +package pool + +import ( + "fmt" + "sort" + "sync" + "time" + + "github.com/innogames/slack-bot/v2/bot/config" + "github.com/innogames/slack-bot/v2/bot/storage" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +const ( + storageKey = "pool" +) + +var ( + ErrResourceLockedByDifferentUser = fmt.Errorf("resources locked by different user") + ErrNoLockedResourceFound = fmt.Errorf("no locked resource found") + ErrNoResourceAvailable = fmt.Errorf("no resource available") +) + +// ResourceLock struct to hold and store the current locks +type ResourceLock struct { + Resource config.Resource `json:"-"` + User string + Reason string + WarningSend bool `json:"-"` + LockUntil time.Time +} + +type pool struct { + locks map[*config.Resource]*ResourceLock + lockDuration time.Duration + mu sync.RWMutex +} + +// GetNewPool create a new pool and initialize it by the local storage +func GetNewPool(cfg *config.Pool) *pool { + var p pool + + p.lockDuration = cfg.LockDuration + + p.locks = make(map[*config.Resource]*ResourceLock) + for _, resource := range cfg.Resources { + p.locks[resource] = nil + } + + keys, _ := storage.GetKeys(storageKey) + if len(keys) == 0 { + return &p + } + + for _, key := range keys { + var lock ResourceLock + if err := storage.Read(storageKey, key, &lock); err != nil { + log.Errorf("[Pool] unable to restore lock for '%s': %s", key, err) + continue + } + + for k := range p.locks { + if k.Name == key { + lock.Resource = *k + p.locks[k] = &lock + break + } + } + } + return &p +} + +// Lock a resource in the pool for a user +func (p *pool) Lock(user, reason, resourceName string) (*ResourceLock, error) { + specificResource := len(resourceName) > 0 + + for k, v := range p.locks { + if v != nil { + // it's already in used + continue + } + + if !specificResource && k.ExplicitLock { + // resource can be locked only specifically + continue + } + + if specificResource && k.Name != resourceName { + // specific resource should be locked but it's not this one. + continue + } + + resourceLock := &ResourceLock{ + Resource: *k, + User: user, + Reason: reason, + LockUntil: time.Now().Add(p.lockDuration), + } + + p.mu.Lock() + defer p.mu.Unlock() + + p.locks[k] = resourceLock + + if err := storage.Write(storageKey, k.Name, resourceLock); err != nil { + log.Error(errors.Wrap(err, "error while storing pool lock entry")) + } + return resourceLock, nil + } + + return nil, ErrNoResourceAvailable +} + +// Extend the lock of a resource in the pool for a user +func (p *pool) ExtendLock(user, resourceName, duration string) (*ResourceLock, error) { + for k, v := range p.locks { + if v == nil { + continue + } + + if k.Name != resourceName { + continue + } + + if v.User != user { + return nil, ErrResourceLockedByDifferentUser + } + + d, err := time.ParseDuration(duration) + if err != nil { + return nil, err + } + + v.LockUntil = v.LockUntil.Add(d) + v.WarningSend = false + + p.locks[k] = v + + if err := storage.Delete(storageKey, k.Name); err != nil { + log.Error(errors.Wrap(err, "error while storing pool lock entry")) + } + if err := storage.Write(storageKey, k.Name, v); err != nil { + log.Error(errors.Wrap(err, "error while storing pool lock entry")) + } + + return v, nil + } + + return nil, ErrNoLockedResourceFound +} + +// Unlock a resource of a user +func (p *pool) Unlock(user, resourceName string) error { + for k, v := range p.locks { + if v == nil { + continue + } + + if k.Name != resourceName { + continue + } + + if v.User != user { + return ErrResourceLockedByDifferentUser + } + + p.locks[k] = nil + + if err := storage.Delete(storageKey, k.Name); err != nil { + log.Error(errors.Wrap(err, "error while storing pool lock entry")) + } + } + + return nil +} + +type lockSorter struct { + resources []*ResourceLock +} + +func (s *lockSorter) Len() int { + return len(s.resources) +} +func (s *lockSorter) Swap(i, j int) { + s.resources[i], s.resources[j] = s.resources[j], s.resources[i] +} +func (s *lockSorter) Less(i, j int) bool { + return s.resources[i].Resource.Name < s.resources[j].Resource.Name +} + +// Get a sorted list of all active locks of a user / all users if userName = "" +func (p *pool) GetLocks(userName string) []*ResourceLock { + var locked []*ResourceLock + byUser := len(userName) > 0 + for _, v := range p.locks { + if v != nil && (!byUser || userName == v.User) { + locked = append(locked, v) + } + } + sorter := &lockSorter{resources: locked} + sort.Sort(sorter) + return sorter.resources +} + +type resourceSorter struct { + resources []*config.Resource +} + +func (s *resourceSorter) Len() int { + return len(s.resources) +} +func (s *resourceSorter) Swap(i, j int) { + s.resources[i], s.resources[j] = s.resources[j], s.resources[i] +} +func (s *resourceSorter) Less(i, j int) bool { + return s.resources[i].Name < s.resources[j].Name +} + +// Get a sorted list of all free / unlocked resources +func (p *pool) GetFree() []*config.Resource { + var free []*config.Resource + for k, v := range p.locks { + if v == nil { + free = append(free, k) + } + } + sorter := &resourceSorter{resources: free} + sort.Sort(sorter) + return sorter.resources +} diff --git a/command/pool/pool_commands.go b/command/pool/pool_commands.go new file mode 100644 index 00000000..da52bcfb --- /dev/null +++ b/command/pool/pool_commands.go @@ -0,0 +1,305 @@ +package pool + +import ( + "fmt" + "strings" + "time" + + "github.com/innogames/slack-bot/v2/bot" + "github.com/innogames/slack-bot/v2/bot/config" + "github.com/innogames/slack-bot/v2/bot/matcher" + "github.com/innogames/slack-bot/v2/bot/msg" + "github.com/innogames/slack-bot/v2/client" + "github.com/slack-go/slack" +) + +// newPoolCommands display usage of the pool +func newPoolCommands(slackClient client.SlackClient, cfg *config.Pool, p *pool) bot.Command { + return &poolCommands{slackClient, cfg, p} +} + +type poolCommands struct { + slackClient client.SlackClient + config *config.Pool + pool *pool +} + +// IsEnabled can be switched on / off via config +func (c *poolCommands) IsEnabled() bool { + return c.config.IsEnabled() +} + +// Matcher to handle commands +func (c *poolCommands) GetMatcher() matcher.Matcher { + var resources []string + for _, res := range c.config.Resources { + resources = append(resources, res.Name) + } + return matcher.NewGroupMatcher( + matcher.NewRegexpMatcher(fmt.Sprintf("pool lock\\b( )?(?P(%s\\b))?(( )?(?P.+))?", strings.Join(resources, "\\b|")), c.lockResource), + matcher.NewRegexpMatcher(fmt.Sprintf("pool unlock( )?(?P(%s))?", strings.Join(resources, "|")), c.unlockResource), + matcher.NewRegexpMatcher("pool locks", c.listUserResources), + matcher.NewRegexpMatcher("pool list( )?(?P(free|used|locked))?", c.listResources), + matcher.NewRegexpMatcher("pool info( )?(?P(free|used|locked))?", c.listPoolInfo), + matcher.NewRegexpMatcher(fmt.Sprintf("pool extend (?P(%s)) (?P([0-9]+[hmsd]))", strings.Join(resources, "|")), c.extend), + ) +} + +// RunAsync function to observe, notify and unlock expired locks +func (c *poolCommands) RunAsync() { + for { + now := time.Now() + nowIn := now.Add(c.config.NotifyExpire) + allLocks := c.pool.GetLocks("") + for _, lock := range allLocks { + if now.After(lock.LockUntil) && lock.WarningSend { + _ = c.pool.Unlock(lock.User, lock.Resource.Name) + c.slackClient.SendToUser(lock.User, fmt.Sprintf("your lock for `%s` expired and got removed", lock.Resource.Name)) + continue + } + + if nowIn.After(lock.LockUntil) && !lock.WarningSend { + blocks := []slack.Block{ + client.GetTextBlock( + fmt.Sprintf("your lock for `%s` is going to expire at %s.\nextend your lock if you need it longer.", lock.Resource.Name, lock.LockUntil.Format(time.RFC1123)), + ), + slack.NewActionBlock( + "extend_30m", + client.GetInteractionButton("action_30m", "30 mins", fmt.Sprintf("pool extend %s 30m", lock.Resource.Name)), + client.GetInteractionButton("action_1h", "1 hour", fmt.Sprintf("pool extend %s 1h", lock.Resource.Name)), + client.GetInteractionButton("action_2h", "2 hours", fmt.Sprintf("pool extend %s 2h", lock.Resource.Name)), + client.GetInteractionButton("action_1d", "1 day", fmt.Sprintf("pool extend %s 24h", lock.Resource.Name)), + client.GetInteractionButton("action_unlock", "unlock now!", fmt.Sprintf("pool unlock %s", lock.Resource.Name)), + ), + } + c.slackClient.SendBlockMessageToUser(lock.User, blocks) + lock.WarningSend = true + } + } + + time.Sleep(1 * time.Minute) + } +} + +func (c *poolCommands) lockResource(match matcher.Result, message msg.Message) { + _, userName := client.GetUserIDAndName(message.GetUser()) + + resourceName := match.GetString("resource") + reason := match.GetString("reason") + + resource, err := c.pool.Lock(userName, reason, resourceName) + if err != nil { + c.slackClient.ReplyError(message, err) + return + } + c.slackClient.SendMessage(message, fmt.Sprintf("`%s` is locked for you until %s!\n%s%s", resource.Resource.Name, resource.LockUntil.Format(time.RFC1123), getFormattedReason(resource.Reason), getAddressesAndFeatures(&resource.Resource))) +} + +func (c *poolCommands) unlockResource(match matcher.Result, message msg.Message) { + _, userName := client.GetUserIDAndName(message.GetUser()) + + resourceName := match.GetString("resource") + + if len(resourceName) == 0 { + lockedByUser := c.pool.GetLocks(userName) + if len(lockedByUser) == 0 { + c.slackClient.SendMessage(message, "you don't have any locks") + return + } + + if len(lockedByUser) > 1 { + var locks []string + var unlockButtons []slack.BlockElement + locks = make([]string, len(lockedByUser)) + unlockButtons = make([]slack.BlockElement, len(lockedByUser)) + + for i, lock := range lockedByUser { + locks[i] = fmt.Sprintf("`%s` until %s\n%s", lock.Resource.Name, lock.LockUntil.Format(time.RFC1123), getFormattedReason(lock.Reason)) + unlockButtons[i] = client.GetInteractionButton(fmt.Sprintf("action_unlock_%s", lock.Resource.Name), lock.Resource.Name, fmt.Sprintf("pool unlock %s", lock.Resource.Name)) + } + + blocks := []slack.Block{ + client.GetTextBlock(fmt.Sprintf("You have multible markets locked: which one should be unlocked?\n%s", strings.Join(locks, "\n"))), + slack.NewActionBlock( + "unlock_market", + unlockButtons..., + ), + } + c.slackClient.SendBlockMessageToUser(userName, blocks) + return + } + + resourceName = lockedByUser[0].Resource.Name + } + + err := c.pool.Unlock(userName, resourceName) + if err != nil { + c.slackClient.ReplyError(message, err) + return + } + c.slackClient.SendMessage(message, fmt.Sprintf("`%s` is free again", resourceName)) +} + +func (c *poolCommands) extend(match matcher.Result, message msg.Message) { + _, userName := client.GetUserIDAndName(message.GetUser()) + + resourceName := match.GetString("resource") + duration := match.GetString("duration") + + res, err := c.pool.ExtendLock(userName, resourceName, duration) + if err != nil { + c.slackClient.ReplyError(message, err) + return + } + if res == nil { + c.slackClient.ReplyError(message, fmt.Errorf("%s expired already", resourceName)) + return + } + + c.slackClient.SendMessage(message, fmt.Sprintf("`%s` got extended until %s", resourceName, res.LockUntil.Format(time.RFC1123))) +} + +func (c *poolCommands) listResources(match matcher.Result, message msg.Message) { + status := match.GetString("status") + + var messages []string + if len(status) == 0 || status == "free" { + messages = append(messages, "*Available:*") + free := c.pool.GetFree() + var resources []string + for _, f := range free { + resources = append(resources, fmt.Sprintf("`%s`", f.Name)) + } + messages = append(messages, strings.Join(resources, ", ")) + } + messages = append(messages, "") + if len(status) == 0 || status == "used" || status == "locked" { + locked := c.pool.GetLocks("") + messages = append(messages, "*Used/Locked:*") + for _, l := range locked { + messages = append(messages, fmt.Sprintf("`%s` locked by %s until %s\n%s", l.Resource.Name, l.User, l.LockUntil.Format(time.RFC1123), getFormattedReason(l.Reason))) + } + } + + c.slackClient.SendMessage(message, strings.Join(messages, "\n")) +} + +func (c *poolCommands) listUserResources(match matcher.Result, message msg.Message) { + _, userName := client.GetUserIDAndName(message.GetUser()) + + lockedByUser := c.pool.GetLocks(userName) + if len(lockedByUser) == 0 { + c.slackClient.SendMessage(message, "you don't have any locks") + return + } + + locks := []string{"*Your locks:*\n"} + for _, lock := range lockedByUser { + locks = append(locks, fmt.Sprintf("`%s` until %s\n%s", lock.Resource.Name, lock.LockUntil.Format(time.RFC1123), getFormattedReason(lock.Reason))) + } + c.slackClient.SendMessage(message, fmt.Sprintf(" %s", strings.Join(locks, "\n"))) +} + +func (c *poolCommands) listPoolInfo(match matcher.Result, message msg.Message) { + status := match.GetString("status") + + var messages []string + if len(status) == 0 || status == "free" { + messages = append(messages, "*Available:*") + free := c.pool.GetFree() + for _, f := range free { + messages = append(messages, fmt.Sprintf("`%s`:\n%s\n", f.Name, getAddressesAndFeatures(f))) + } + } + messages = append(messages, "") + if len(status) == 0 || status == "used" || status == "locked" { + locked := c.pool.GetLocks("") + messages = append(messages, "*Used/Locked:*") + for _, l := range locked { + messages = append(messages, fmt.Sprintf("`%s`:\n locked by %s until %s\n%s%s", l.Resource.Name, l.User, l.LockUntil.Format(time.RFC1123), getFormattedReason(l.Reason), getAddressesAndFeatures(&l.Resource))) + } + } + + c.slackClient.SendMessage(message, strings.Join(messages, "\n")) +} + +func getFormattedReason(reason string) string { + if len(reason) == 0 { + return "" + } + return fmt.Sprintf("_%s_\n", reason) +} + +func getAddressesAndFeatures(resource *config.Resource) string { + var lines []string + lines = append(lines, ">_Addresses:_") + for _, address := range resource.Addresses { + lines = append(lines, fmt.Sprintf(">- %s", address)) + } + + lines = append(lines, ">_Features:_") + for _, address := range resource.Features { + lines = append(lines, fmt.Sprintf(">- %s", address)) + } + return strings.Join(lines, "\n") +} + +// GetHelp documentation about the command and how to use it +func (c *poolCommands) GetHelp() []bot.Help { + return []bot.Help{ + { + Command: "pool list ", + Description: "display available / free resources of the pool", + Category: category, + Examples: []string{ + "pool list _list all resources_", + "pool list free _list available resources only_", + "pool list locked _list locked resources only_", + }, + }, + { + Command: "pool info", + Description: "list detailed infos about the pool", + Category: category, + Examples: []string{ + "pool info _list detailed infos about resources_", + }, + }, + { + Command: "pool lock ", + Description: "lock a resource with a reason, resource and reason are optional, no resource will lock any available one", + Category: category, + Examples: []string{ + "pool lock _lock an available resource_", + "pool lock with reason _lock an available resource with a reason_", + "pool lock xa _lock a specific resource_", + "pool lock xa with reason _lock a specific resource with a reason_", + }, + }, + { + Command: "pool locks", + Description: "show your locked resources", + Category: category, + Examples: []string{ + "pool locks _show your current locks_", + }, + }, + { + Command: "pool unlock ", + Description: "unlock your locked and/or specific resource, if you have more then one resource locked displays the list of locked resources", + Category: category, + Examples: []string{ + "pool unlock _unlock your resource_", + "pool unlock xa _lock a specific resource_", + }, + }, + { + Command: "pool extend ", + Description: "extend the time a resource is locked", + Category: category, + Examples: []string{ + "pool extend xa 30m _extend lock of resource xa by 30mins_", + }, + }, + } +} diff --git a/command/pullrequest/pull_request.go b/command/pullrequest/pull_request.go index e3b32508..b2f77a8f 100644 --- a/command/pullrequest/pull_request.go +++ b/command/pullrequest/pull_request.go @@ -307,6 +307,11 @@ func (c command) notifyBuildStatus(prw *pullRequestWatch) { } func (c command) notifyPullRequestStatus(prw *pullRequestWatch) { + + if !c.cfg.Notifications.PullRequestStatusMergeable { + return + } + if prw.DidNotifyMergeable { return } diff --git a/command/queue/list_command.go b/command/queue/list_command.go index 9ef99edd..bda36109 100644 --- a/command/queue/list_command.go +++ b/command/queue/list_command.go @@ -122,7 +122,7 @@ func (c *listCommand) getQueueAsBlocks(message msg.Message, filter filterFunc) ( blocks, slack.NewActionBlock( "", - client.GetInteractionButton("Refresh :arrows_counterclockwise:", message.GetText()), + client.GetInteractionButton("refresh", "Refresh :arrows_counterclockwise:", message.GetText()), ), ) diff --git a/config.example.yaml b/config.example.yaml index e0620da7..6f83109f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -84,6 +84,21 @@ github: # apikey: iamtheapifromopenweathermap # location: Hamburg +# optional define a pool of resources which can be locked / unlocked by a user +#pool: +# lockduration: 2h # default duration to lock a resource +# notifyexpire: 30m # time to notify the user before a lock expires +# resources: +# - name: xa +# explicitlock: true # will not be used for auto lock via "pool lock" can be locked only explicit via "pool lock xa" +# addresses: # additional addresses which could be useful +# - "market: https://xa1.local" +# - "admin: https://xa-admin.local" +# - "web: https://xa.local" +# features: # list of features the resource provides +# - "web beauty" +# - "usb plugs" + logger: level: info file: ./bot.log diff --git a/mocks/SlackClient.go b/mocks/SlackClient.go index 5d0dc586..78784d66 100644 --- a/mocks/SlackClient.go +++ b/mocks/SlackClient.go @@ -145,6 +145,26 @@ func (_m *SlackClient) SendMessage(ref msg.Ref, text string, options ...slack.Ms return r0 } +func (_m *SlackClient) SendBlockMessageToUser(user string, blocks []slack.Block, options ...slack.MsgOption) string { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, user, blocks) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 string + if rf, ok := ret.Get(0).(func(string, []slack.Block, ...slack.MsgOption) string); ok { + r0 = rf(user, blocks, options...) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + // SendToUser provides a mock function with given fields: user, text func (_m *SlackClient) SendToUser(user string, message string) { _m.Called(user, message)