From b859f7eff6e01a22ea758fe6772877e3ea40937b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Thu, 31 Aug 2023 11:14:54 +0200 Subject: [PATCH] New command "notify user @whateveruser active" --- client/slack.go | 8 ++++ command/commands.go | 1 + command/jenkins/inform_idle_test.go | 2 +- command/openai/openai_test.go | 16 +++---- command/pullrequest/github_test.go | 5 +- command/queue/queue.go | 22 +++++++++ command/user_status.go | 72 +++++++++++++++++++++++++++++ command/user_status_test.go | 71 ++++++++++++++++++++++++++++ mocks/Client.go | 11 ++--- mocks/SlackClient.go | 37 ++++++++++++--- 10 files changed, 222 insertions(+), 23 deletions(-) create mode 100644 command/user_status.go create mode 100644 command/user_status_test.go diff --git a/client/slack.go b/client/slack.go index 06a8d93e..2eddabcf 100644 --- a/client/slack.go +++ b/client/slack.go @@ -112,6 +112,9 @@ type SlackClient interface { // GetThreadMessages loads message from a given thread GetThreadMessages(ref msg.Ref) ([]slack.Message, error) + + // GetUserPresence returns the current presence of a user, using the "users.getPresence" API + GetUserPresence(user string) (*slack.UserPresence, error) } // Slack is wrapper to the slack.Client which also holds the the socketmode.Client and all needed config @@ -289,6 +292,11 @@ func (s *Slack) GetThreadMessages(ref msg.Ref) ([]slack.Message, error) { return allMessages, nil } +// GetUserPresence returns the current presence of a user, using the "users.getPresence" API +func (s *Slack) GetUserPresence(user string) (*slack.UserPresence, error) { + return s.Client.GetUserPresence(user) +} + // GetUserIDAndName returns the user-id and user-name based on a identifier. If can get a user-id or name func GetUserIDAndName(identifier string) (id string, name string) { identifier = strings.TrimPrefix(identifier, "@") diff --git a/command/commands.go b/command/commands.go index 02768501..65ff5714 100644 --- a/command/commands.go +++ b/command/commands.go @@ -37,6 +37,7 @@ func GetCommands(slackClient client.SlackClient, cfg config.Config) *bot.Command NewDelayCommand(base), NewRandomCommand(base), NewHelpCommand(base, commands), + newUserStatusCommand(base), weather.NewWeatherCommand(base, cfg.OpenWeather), diff --git a/command/jenkins/inform_idle_test.go b/command/jenkins/inform_idle_test.go index 5f44f530..715c7d04 100644 --- a/command/jenkins/inform_idle_test.go +++ b/command/jenkins/inform_idle_test.go @@ -58,7 +58,7 @@ func TestInformIdle(t *testing.T) { assert.True(t, actual) // wait until watcher is ready - time.Sleep(time.Millisecond * 100) + queue.WaitTillHavingNoQueuedMessage() assert.Equal(t, 0, queue.CountCurrentJobs()) }) diff --git a/command/openai/openai_test.go b/command/openai/openai_test.go index accf32e7..7d2f8718 100644 --- a/command/openai/openai_test.go +++ b/command/openai/openai_test.go @@ -6,13 +6,13 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/innogames/slack-bot/v2/bot" "github.com/innogames/slack-bot/v2/bot/config" "github.com/innogames/slack-bot/v2/bot/msg" "github.com/innogames/slack-bot/v2/bot/storage" "github.com/innogames/slack-bot/v2/bot/util" + "github.com/innogames/slack-bot/v2/command/queue" "github.com/innogames/slack-bot/v2/mocks" "github.com/slack-go/slack" "github.com/stretchr/testify/assert" @@ -125,7 +125,7 @@ data: [DONE]`, mocks.AssertSlackMessage(slackClient, ref, "The answer is 2", mock.Anything, mock.Anything) actual := commands.Run(message) - time.Sleep(time.Millisecond * 100) + queue.WaitTillHavingNoQueuedMessage() assert.True(t, actual) // test reply in different context -> nothing @@ -135,7 +135,7 @@ data: [DONE]`, message.Thread = "4321" actual = commands.Run(message) - time.Sleep(time.Millisecond * 100) + queue.WaitTillHavingNoQueuedMessage() assert.False(t, actual) // test reply in same context -> ask openai with history @@ -150,7 +150,7 @@ data: [DONE]`, mocks.AssertSlackMessage(slackClient, message, "The answer is 3", mock.Anything) actual = commands.Run(message) - time.Sleep(time.Millisecond * 100) + queue.WaitTillHavingNoQueuedMessage() assert.True(t, actual) }) @@ -192,7 +192,7 @@ data: [DONE]`, mocks.AssertSlackMessage(slackClient, ref, "Incorrect API key provided: sk-1234**************************************567.", mock.Anything, mock.Anything) actual := commands.Run(message) - time.Sleep(100 * time.Millisecond) + queue.WaitTillHavingNoQueuedMessage() assert.True(t, actual) }) @@ -234,7 +234,7 @@ data: [DONE]`, mocks.AssertSlackMessage(slackClient, message, "Incorrect API key provided: sk-1234**************************************567.", mock.Anything, mock.Anything) actual := commands.Run(message) - time.Sleep(100 * time.Millisecond) + queue.WaitTillHavingNoQueuedMessage() assert.True(t, actual) }) @@ -319,7 +319,7 @@ data: [DONE]`, mocks.AssertError(slackClient, ref, "can't load thread messages: openai not reachable") slackClient.On("GetThreadMessages", ref).Once().Return([]slack.Message{}, errors.New("openai not reachable")) actual := commands.Run(message) - time.Sleep(time.Millisecond * 50) + queue.WaitTillHavingNoQueuedMessage() assert.True(t, actual) // then a successful attempt @@ -336,7 +336,7 @@ data: [DONE]`, mocks.AssertSlackMessage(slackClient, ref, "Jolo!", mock.Anything, mock.Anything) actual = commands.Run(message) - time.Sleep(time.Millisecond * 100) + queue.WaitTillHavingNoQueuedMessage() assert.True(t, actual) }) } diff --git a/command/pullrequest/github_test.go b/command/pullrequest/github_test.go index b82e6eb9..6d2fa54d 100644 --- a/command/pullrequest/github_test.go +++ b/command/pullrequest/github_test.go @@ -11,6 +11,7 @@ import ( "github.com/innogames/slack-bot/v2/bot/matcher" "github.com/innogames/slack-bot/v2/bot/msg" "github.com/innogames/slack-bot/v2/bot/util" + "github.com/innogames/slack-bot/v2/command/queue" "github.com/innogames/slack-bot/v2/mocks" "github.com/slack-go/slack" "github.com/stretchr/testify/assert" @@ -43,7 +44,7 @@ func TestGithub(t *testing.T) { mocks.AssertReaction(slackClient, "x", message) actual := commands.Run(message) - time.Sleep(time.Millisecond * 300) + queue.WaitTillHavingNoQueuedMessage() assert.Equal(t, true, actual) }) @@ -57,7 +58,7 @@ func TestGithub(t *testing.T) { mocks.AssertReaction(slackClient, "twisted_rightwards_arrows", message) actual := commands.Run(message) - time.Sleep(time.Millisecond * 300) + time.Sleep(time.Millisecond * 200) assert.Equal(t, true, actual) }) diff --git a/command/queue/queue.go b/command/queue/queue.go index 043cc251..6725ff75 100644 --- a/command/queue/queue.go +++ b/command/queue/queue.go @@ -3,6 +3,7 @@ package queue import ( "strings" "sync" + "time" "github.com/innogames/slack-bot/v2/bot/msg" "github.com/innogames/slack-bot/v2/bot/storage" @@ -96,3 +97,24 @@ func executeFallbackCommand() { _ = storage.DeleteCollection(storageKey) } + +// WaitTillHavingNoQueuedMessage will wait in test context until all background tasks are done. +// we use a deadline of 2s until we mark the test as failed +func WaitTillHavingNoQueuedMessage() { + deadline := time.Second * 2 + timeout := time.NewTimer(deadline) + ticker := time.NewTicker(time.Millisecond * 5) + defer ticker.Stop() + defer timeout.Stop() + + for { + select { + case <-timeout.C: + panic("Queue is still full after " + deadline.String()) + case <-ticker.C: + if CountCurrentJobs() == 0 { + return + } + } + } +} diff --git a/command/user_status.go b/command/user_status.go new file mode 100644 index 00000000..25e99a9c --- /dev/null +++ b/command/user_status.go @@ -0,0 +1,72 @@ +package command + +import ( + "fmt" + "time" + + "github.com/innogames/slack-bot/v2/command/queue" + + "github.com/innogames/slack-bot/v2/bot" + "github.com/innogames/slack-bot/v2/bot/matcher" + "github.com/innogames/slack-bot/v2/bot/msg" +) + +const notifyCheckInterval = time.Minute * 1 + +// Command which informs the user when the given user got active +func newUserStatusCommand(base bot.BaseCommand) *userStatus { + return &userStatus{ + base, + notifyCheckInterval, + } +} + +type userStatus struct { + bot.BaseCommand + checkInterval time.Duration +} + +func (c *userStatus) GetMatcher() matcher.Matcher { + return matcher.NewRegexpMatcher(`notify user <@(?P.*)> (?P(away|active))`, c.NotifyUserActive) +} + +func (c *userStatus) NotifyUserActive(match matcher.Result, message msg.Message) { + user := match.GetString("user") + expectedStatus := match.GetString("status") + + // in case of bot restart: restart this command again + runningCommand := queue.AddRunningCommand(message, message.Text) + + c.AddReaction("⌛", message) + go func() { + defer c.RemoveReaction("⌛", message) + defer runningCommand.Done() + + for { + presence, err := c.SlackClient.GetUserPresence(user) + if err != nil { + c.ReplyError(message, err) + return + } + + if presence.Presence == expectedStatus { + c.SendMessage(message, fmt.Sprintf("User <@%s> is %s now!", user, presence.Presence)) + return + } + + time.Sleep(c.checkInterval) + } + }() +} + +func (c *userStatus) GetHelp() []bot.Help { + return []bot.Help{ + { + Command: "notify user active", + Description: "Inform you if the given user change the slack status to active.", + Examples: []string{ + "notify user @myboss active", + }, + }, + } +} diff --git a/command/user_status_test.go b/command/user_status_test.go new file mode 100644 index 00000000..7fbee9aa --- /dev/null +++ b/command/user_status_test.go @@ -0,0 +1,71 @@ +package command + +import ( + "fmt" + "testing" + "time" + + "github.com/innogames/slack-bot/v2/bot" + "github.com/innogames/slack-bot/v2/bot/msg" + "github.com/innogames/slack-bot/v2/command/queue" + "github.com/innogames/slack-bot/v2/mocks" + "github.com/slack-go/slack" + "github.com/stretchr/testify/assert" +) + +func TestUserStatus(t *testing.T) { + slackClient := &mocks.SlackClient{} + + base := bot.BaseCommand{SlackClient: slackClient} + command := newUserStatusCommand(base) + + commands := bot.Commands{} + commands.AddCommand(command) + + t.Run("Invalid command", func(t *testing.T) { + message := msg.Message{} + message.Text = "notify for something" + + actual := commands.Run(message) + assert.False(t, actual) + }) + + t.Run("Check with error", func(t *testing.T) { + message := msg.Message{} + message.Text = "notify user <@U123456> active" + + err := fmt.Errorf("some slack error") + slackClient.On("GetUserPresence", "U123456").Once().Return(nil, err) + + mocks.AssertReaction(slackClient, "⌛", message) + mocks.AssertRemoveReaction(slackClient, "⌛", message) + mocks.AssertError(slackClient, message, err) + + actual := commands.Run(message) + assert.True(t, actual) + queue.WaitTillHavingNoQueuedMessage() + }) + + t.Run("Check user getting active", func(t *testing.T) { + message := msg.Message{} + message.Text = "notify user <@U123456> active" + + command.checkInterval = time.Millisecond * 1 + presenceAway := &slack.UserPresence{ + Presence: "away", + } + presenceActive := &slack.UserPresence{ + Presence: "active", + } + slackClient.On("GetUserPresence", "U123456").Once().Return(presenceAway, nil) + slackClient.On("GetUserPresence", "U123456").Once().Return(presenceActive, nil) + + mocks.AssertReaction(slackClient, "⌛", message) + mocks.AssertRemoveReaction(slackClient, "⌛", message) + mocks.AssertSlackMessage(slackClient, message, "User <@U123456> is active now!") + + actual := commands.Run(message) + assert.True(t, actual) + queue.WaitTillHavingNoQueuedMessage() + }) +} diff --git a/mocks/Client.go b/mocks/Client.go index 113cc403..0825901a 100644 --- a/mocks/Client.go +++ b/mocks/Client.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.22.1. DO NOT EDIT. +// Code generated by mockery v2.33.0. DO NOT EDIT. package mocks @@ -91,13 +91,12 @@ func (_m *Client) GetJob(ctx context.Context, id string) (*gojenkins.Job, error) return r0, r1 } -type mockConstructorTestingTNewClient interface { +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { mock.TestingT Cleanup(func()) -} - -// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewClient(t mockConstructorTestingTNewClient) *Client { +}) *Client { mock := &Client{} mock.Mock.Test(t) diff --git a/mocks/SlackClient.go b/mocks/SlackClient.go index a2c95c4d..ce3ef65f 100644 --- a/mocks/SlackClient.go +++ b/mocks/SlackClient.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.22.1. DO NOT EDIT. +// Code generated by mockery v2.33.0. DO NOT EDIT. package mocks @@ -99,6 +99,32 @@ func (_m *SlackClient) GetThreadMessages(ref msg.Ref) ([]slack.Message, error) { return r0, r1 } +// GetUserPresence provides a mock function with given fields: user +func (_m *SlackClient) GetUserPresence(user string) (*slack.UserPresence, error) { + ret := _m.Called(user) + + var r0 *slack.UserPresence + var r1 error + if rf, ok := ret.Get(0).(func(string) (*slack.UserPresence, error)); ok { + return rf(user) + } + if rf, ok := ret.Get(0).(func(string) *slack.UserPresence); ok { + r0 = rf(user) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*slack.UserPresence) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(user) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // RemoveReaction provides a mock function with given fields: reaction, ref func (_m *SlackClient) RemoveReaction(reaction util.Reaction, ref msg.Ref) { _m.Called(reaction, ref) @@ -189,13 +215,12 @@ func (_m *SlackClient) SendToUser(user string, text string) { _m.Called(user, text) } -type mockConstructorTestingTNewSlackClient interface { +// NewSlackClient creates a new instance of SlackClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSlackClient(t interface { mock.TestingT Cleanup(func()) -} - -// NewSlackClient creates a new instance of SlackClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewSlackClient(t mockConstructorTestingTNewSlackClient) *SlackClient { +}) *SlackClient { mock := &SlackClient{} mock.Mock.Test(t)