diff --git a/httpsettings/settings.go b/httpsettings/settings.go index a4c383e70fc4aeda7a90b1d5c097a6a0ccfc6346..124ce8b7fc964bb132efe2d2ae6b2fe4cb8a9034 100644 --- a/httpsettings/settings.go +++ b/httpsettings/settings.go @@ -12,6 +12,7 @@ import ( "gitlab.com/lightmeter/controlcenter/httpmiddleware" "gitlab.com/lightmeter/controlcenter/meta" "gitlab.com/lightmeter/controlcenter/notification" + "gitlab.com/lightmeter/controlcenter/notification/slack" "gitlab.com/lightmeter/controlcenter/pkg/httperror" "gitlab.com/lightmeter/controlcenter/po" "gitlab.com/lightmeter/controlcenter/settings" @@ -28,16 +29,25 @@ type Settings struct { reader *meta.Reader initialSetupSettings *settings.InitialSetupSettings - notificationCenter notification.Center + notificationCenter *notification.Center handlers map[string]func(http.ResponseWriter, *http.Request) error + + slackNotifier *slack.Notifier } func NewSettings(writer *meta.AsyncWriter, reader *meta.Reader, initialSetupSettings *settings.InitialSetupSettings, - notificationCenter notification.Center, + notificationCenter *notification.Center, + slackNotifier *slack.Notifier, ) *Settings { - s := &Settings{writer: writer, reader: reader, initialSetupSettings: initialSetupSettings, notificationCenter: notificationCenter} + s := &Settings{ + writer: writer, + reader: reader, + initialSetupSettings: initialSetupSettings, + notificationCenter: notificationCenter, + slackNotifier: slackNotifier, + } s.handlers = map[string]func(http.ResponseWriter, *http.Request) error{ "initSetup": s.InitialSetupHandler, "notification": s.NotificationSettingsHandler, @@ -358,7 +368,11 @@ func (h *Settings) NotificationSettingsHandler(w http.ResponseWriter, r *http.Re Language: messengerLanguage, } - if err := h.notificationCenter.AddSlackNotifier(slackNotificationsSettings); err != nil { + testNotifier := h.slackNotifier.DeriveNotifierWithCustomSettingsFetcher(notification.AlwaysAllowPolicies, func() (*settings.SlackNotificationsSettings, error) { + return &slackNotificationsSettings, nil + }) + + if err := testNotifier.SendTestNotification(); err != nil { err := errorutil.Wrap(err, "Error register slack notifier "+err.Error()) return httperror.NewHTTPStatusCodeError(http.StatusBadRequest, err) } diff --git a/httpsettings/settings_page_test.go b/httpsettings/settings_page_test.go index d91d28f2dd3af241c53b009179143d7ea82bc99d..c5cb1d3a40ad3cfe68b5e3d9b4777a2f09aaf31f 100644 --- a/httpsettings/settings_page_test.go +++ b/httpsettings/settings_page_test.go @@ -6,14 +6,19 @@ package httpsettings import ( "encoding/json" + slackAPI "github.com/slack-go/slack" . "github.com/smartystreets/goconvey/convey" "gitlab.com/lightmeter/controlcenter/httpmiddleware" + "gitlab.com/lightmeter/controlcenter/i18n/translator" "gitlab.com/lightmeter/controlcenter/lmsqlite3" "gitlab.com/lightmeter/controlcenter/meta" _ "gitlab.com/lightmeter/controlcenter/meta/migrations" + "gitlab.com/lightmeter/controlcenter/notification" + "gitlab.com/lightmeter/controlcenter/notification/slack" "gitlab.com/lightmeter/controlcenter/settings" "gitlab.com/lightmeter/controlcenter/util/errorutil" "gitlab.com/lightmeter/controlcenter/util/testutil" + "golang.org/x/text/message/catalog" "net/http" "net/http/httptest" "net/url" @@ -41,10 +46,20 @@ func TestSettingsPage(t *testing.T) { writer := runner.Writer() - fakeCenter := &fakeNotificationCenter{} + notifier := &fakeNotifier{} + + slackNotifier := slack.New(notification.AlwaysAllowPolicies, m.Reader) + + // don't use slack api, mocking the PostMessage call + slackNotifier.MessagePosterBuilder = func(client *slackAPI.Client) slack.MessagePoster { + return &fakeSlackPoster{} + } + + nc := notification.New(m.Reader, translator.New(catalog.NewBuilder()), []notification.Notifier{notifier, slackNotifier}) + initialSetupSettings := settings.NewInitialSetupSettings(&dummySubscriber{}) - setup := NewSettings(writer, m.Reader, initialSetupSettings, fakeCenter) + setup := NewSettings(writer, m.Reader, initialSetupSettings, nc, slackNotifier) // Approach: as for now we have independent endpoints, we instantiate one server per endpoint // But as soon as we unify them all in a single one, that'll not be needed anymore @@ -64,10 +79,10 @@ func TestSettingsPage(t *testing.T) { So(err, ShouldBeNil) expected := map[string]interface{}{ - "slack_notifications": map[string]interface{}{"bearer_token": "", "channel": "", "enabled": nil, "language":""}, + "slack_notifications": map[string]interface{}{"bearer_token": "", "channel": "", "enabled": nil, "language": ""}, "general": map[string]interface{}{ "postfix_public_ip": "", - "app_language":"", + "app_language": "", }, } @@ -87,10 +102,10 @@ func TestSettingsPage(t *testing.T) { { r, err := c.PostForm(settingsServer.URL+"?setting=notification", url.Values{ - "messenger_kind": {"slack"}, - "messenger_token": {"some_token"}, - "messenger_channel": {"some_channel"}, - "messenger_enabled": {"true"}, + "messenger_kind": {"slack"}, + "messenger_token": {"some_token"}, + "messenger_channel": {"some_channel"}, + "messenger_enabled": {"true"}, "messenger_language": {"en"}, }) So(err, ShouldBeNil) @@ -107,10 +122,10 @@ func TestSettingsPage(t *testing.T) { So(err, ShouldBeNil) expected := map[string]interface{}{ - "slack_notifications": map[string]interface{}{"bearer_token": "some_token", "channel": "some_channel", "enabled": true, "language":"en"}, + "slack_notifications": map[string]interface{}{"bearer_token": "some_token", "channel": "some_channel", "enabled": true, "language": "en"}, "general": map[string]interface{}{ "postfix_public_ip": "11.22.33.44", - "app_language": "en", + "app_language": "en", }, } @@ -122,10 +137,10 @@ func TestSettingsPage(t *testing.T) { { r, err := c.PostForm(settingsServer.URL+"?setting=notification", url.Values{ - "messenger_kind": {"slack"}, - "messenger_token": {"some_token"}, - "messenger_channel": {"some_channel"}, - "messenger_enabled": {"false"}, + "messenger_kind": {"slack"}, + "messenger_token": {"some_token"}, + "messenger_channel": {"some_channel"}, + "messenger_enabled": {"false"}, "messenger_language": {"en"}, }) So(err, ShouldBeNil) @@ -142,15 +157,14 @@ func TestSettingsPage(t *testing.T) { So(err, ShouldBeNil) expected := map[string]interface{}{ - "slack_notifications": map[string]interface{}{"bearer_token": "some_token", "channel": "some_channel", "enabled": false, "language":"en"}, + "slack_notifications": map[string]interface{}{"bearer_token": "some_token", "channel": "some_channel", "enabled": false, "language": "en"}, "general": map[string]interface{}{ "postfix_public_ip": "", - "app_language": "", + "app_language": "", }, } So(body, ShouldResemble, expected) }) - }) } diff --git a/httpsettings/settings_test.go b/httpsettings/settings_test.go index cc126e6d7c3aa723f36f11fa69e856b31eb9847e..09810958ee9a2c9b71ac30a278401753708d2669 100644 --- a/httpsettings/settings_test.go +++ b/httpsettings/settings_test.go @@ -8,6 +8,7 @@ import ( "context" "errors" "github.com/rs/zerolog/log" + slackAPI "github.com/slack-go/slack" . "github.com/smartystreets/goconvey/convey" "gitlab.com/lightmeter/controlcenter/httpmiddleware" "gitlab.com/lightmeter/controlcenter/i18n/translator" @@ -15,6 +16,7 @@ import ( "gitlab.com/lightmeter/controlcenter/meta" _ "gitlab.com/lightmeter/controlcenter/meta/migrations" "gitlab.com/lightmeter/controlcenter/notification" + "gitlab.com/lightmeter/controlcenter/notification/slack" "gitlab.com/lightmeter/controlcenter/settings" "gitlab.com/lightmeter/controlcenter/settings/globalsettings" "gitlab.com/lightmeter/controlcenter/util/errorutil" @@ -39,26 +41,26 @@ func (*dummySubscriber) Subscribe(ctx context.Context, email string) error { return nil } -type fakeNotificationCenter struct { - shouldFailToAddSlackNotifier bool +type fakeNotifier struct { } -func (c *fakeNotificationCenter) Notify(center notification.Notification) error { +func (c *fakeNotifier) Notify(notification.Notification, translator.Translator) error { log.Info().Msg("send notification") return nil } -func (c *fakeNotificationCenter) AddSlackNotifier(notificationsSettings settings.SlackNotificationsSettings) error { - log.Info().Msg("Add slack") - if c.shouldFailToAddSlackNotifier { - return errors.New("Invalid slack notifier") - } +func init() { + lmsqlite3.Initialize(lmsqlite3.Options{}) +} - return nil +type fakeSlackPoster struct { + err error } -func init() { - lmsqlite3.Initialize(lmsqlite3.Options{}) +var fakeSlackError = errors.New(`Some Slack Error`) + +func (p *fakeSlackPoster) PostMessage(channelID string, options ...slackAPI.MsgOption) (string, string, error) { + return "", "", p.err } func TestInitialSetup(t *testing.T) { @@ -78,10 +80,20 @@ func TestInitialSetup(t *testing.T) { writer := runner.Writer() - fakeCenter := &fakeNotificationCenter{} initialSetupSettings := settings.NewInitialSetupSettings(&dummySubscriber{}) - setup := NewSettings(writer, m.Reader, initialSetupSettings, fakeCenter) + fakeNotifier := &fakeNotifier{} + + slackNotifier := slack.New(notification.AlwaysAllowPolicies, m.Reader) + + // don't use slack api, mocking the PostMessage call + slackNotifier.MessagePosterBuilder = func(client *slackAPI.Client) slack.MessagePoster { + return &fakeSlackPoster{} + } + + center := notification.New(m.Reader, translator.New(catalog.NewBuilder()), []notification.Notifier{slackNotifier, fakeNotifier}) + + setup := NewSettings(writer, m.Reader, initialSetupSettings, center, slackNotifier) chain := httpmiddleware.New() handler := chain.WithError(httpmiddleware.CustomHTTPHandler(setup.SettingsForward)) @@ -196,10 +208,20 @@ func TestSettingsSetup(t *testing.T) { defer func() { cancel(); done() }() writer := runner.Writer() - fakeCenter := &fakeNotificationCenter{} initialSetupSettings := settings.NewInitialSetupSettings(&dummySubscriber{}) - setup := NewSettings(writer, m.Reader, initialSetupSettings, fakeCenter) + fakeNotifier := &fakeNotifier{} + + slackNotifier := slack.New(notification.AlwaysAllowPolicies, m.Reader) + + // don't use slack api, mocking the PostMessage call + slackNotifier.MessagePosterBuilder = func(client *slackAPI.Client) slack.MessagePoster { + return &fakeSlackPoster{} + } + + center := notification.New(m.Reader, translator.New(catalog.NewBuilder()), []notification.Notifier{slackNotifier, fakeNotifier}) + + setup := NewSettings(writer, m.Reader, initialSetupSettings, center, slackNotifier) chain := httpmiddleware.New() handler := chain.WithError(httpmiddleware.CustomHTTPHandler(setup.SettingsForward)) @@ -294,7 +316,6 @@ func (c *fakeContent) TplString() string { return "Hell world!, Mister Donutloop 2" } -// todo(marcel) before we create a release stub out the slack api func TestIntegrationSettingsSetup(t *testing.T) { Convey("Integration Settings Setup", t, func() { conn, closeConn := testutil.TempDBConnection(t) @@ -310,12 +331,20 @@ func TestIntegrationSettingsSetup(t *testing.T) { defer func() { cancel(); done() }() writer := runner.Writer() - fakeCenter := &fakeNotificationCenter{} initialSetupSettings := settings.NewInitialSetupSettings(&dummySubscriber{}) - setup := NewSettings(writer, m.Reader, initialSetupSettings, fakeCenter) + slackNotifier := slack.New(notification.AlwaysAllowPolicies, m.Reader) - center := notification.New(m.Reader, translator.New(catalog.NewBuilder())) + fakeSlackPoster := &fakeSlackPoster{err: nil} + + // don't use slack api, mocking the PostMessage call + slackNotifier.MessagePosterBuilder = func(client *slackAPI.Client) slack.MessagePoster { + return fakeSlackPoster + } + + center := notification.New(m.Reader, translator.New(catalog.NewBuilder()), []notification.Notifier{slackNotifier}) + + setup := NewSettings(writer, m.Reader, initialSetupSettings, center, slackNotifier) chain := httpmiddleware.New() handler := chain.WithError(httpmiddleware.CustomHTTPHandler(setup.SettingsForward)) @@ -330,7 +359,7 @@ func TestIntegrationSettingsSetup(t *testing.T) { Convey("send valid values", func() { r, err := c.PostForm(settingsURL, url.Values{ "messenger_kind": {"slack"}, - "messenger_token": {"xoxb-1388191062644-1385067635637-iXfDIfcPO3HKHEjLZY2seVX6"}, + "messenger_token": {"some_valid_key"}, "messenger_channel": {"general"}, "messenger_enabled": {"true"}, "messenger_language": {"de"}, @@ -341,7 +370,7 @@ func TestIntegrationSettingsSetup(t *testing.T) { r, err = c.PostForm(settingsURL, url.Values{ "messenger_kind": {"slack"}, - "messenger_token": {"xoxb-1388191062644-1385067635637-iXfDIfcPO3HKHEjLZY2seVX6"}, + "messenger_token": {"some_valid_key"}, "messenger_channel": {"general"}, "messenger_enabled": {"true"}, "messenger_language": {"en"}, @@ -355,7 +384,7 @@ func TestIntegrationSettingsSetup(t *testing.T) { So(err, ShouldBeNil) So(mo.Channel, ShouldEqual, "general") - So(mo.BearerToken, ShouldEqual, "xoxb-1388191062644-1385067635637-iXfDIfcPO3HKHEjLZY2seVX6") + So(mo.BearerToken, ShouldEqual, "some_valid_key") content := new(fakeContent) notification := notification.Notification{ @@ -368,11 +397,11 @@ func TestIntegrationSettingsSetup(t *testing.T) { }) Convey("Fails if slack validations fail", func() { - fakeCenter.shouldFailToAddSlackNotifier = true + fakeSlackPoster.err = fakeSlackError r, err := c.PostForm(settingsURL, url.Values{ "messenger_kind": {"slack"}, - "messenger_token": {"sjdfklsjdfkljfs"}, + "messenger_token": {"some_invalid_key"}, "messenger_channel": {"donutloop"}, "messenger_enabled": {"true"}, }) @@ -384,7 +413,6 @@ func TestIntegrationSettingsSetup(t *testing.T) { err = m.Reader.RetrieveJson(dummyContext, "messenger_slack", mo) So(errors.Is(err, meta.ErrNoSuchKey), ShouldBeTrue) }) - }) }) } diff --git a/insights/core/access.go b/insights/core/access.go index bf0966e2b1d9dff517d6c691de38dbcbf0a8b668..cde7cc50668d89f9654a1f567993eca4998f6881 100644 --- a/insights/core/access.go +++ b/insights/core/access.go @@ -392,6 +392,21 @@ type InsightProperties struct { Content Content `json:"content"` } +// implements notification.Content +func (p InsightProperties) String() string { + return p.Content.String() +} + +// implements notification.Content +func (p InsightProperties) TplString() string { + return p.Content.TplString() +} + +// implements notification.Content +func (p InsightProperties) Args() []interface{} { + return p.Content.Args() +} + type Creator interface { GenerateInsight(*sql.Tx, InsightProperties) error } diff --git a/insights/default.go b/insights/default.go index f45ea34080f807f1f311e783276566cff97f51fa..ca7bde6055815724484712fdc5e3b353593f17e9 100644 --- a/insights/default.go +++ b/insights/default.go @@ -29,7 +29,7 @@ func defaultDetectors(creator *creator, options core.Options) []core.Detector { func NewEngine( workspaceDir string, - notificationCenter notification.Center, + notificationCenter *notification.Center, options core.Options, ) (*Engine, error) { return NewCustomEngine(workspaceDir, notificationCenter, options, defaultDetectors, executeAdditionalDetectorsInitialActions) diff --git a/insights/engine.go b/insights/engine.go index a7746e8c70e2412d41ad90e315e6bcbcd4caded3..355222af09f79414ead647164c58d195cafef8ca 100644 --- a/insights/engine.go +++ b/insights/engine.go @@ -32,7 +32,7 @@ type Engine struct { func NewCustomEngine( workspaceDir string, - notificationCenter notification.Center, + notificationCenter *notification.Center, options core.Options, buildDetectors func(*creator, core.Options) []core.Detector, additionalActions func([]core.Detector, dbconn.RwConn) error, diff --git a/insights/fetcher.go b/insights/fetcher.go index 80d0b051e2f22a8db1a611448c1abcb90ffaae2c..849145de7a6c8218129fe00539df66e8a1f44667 100644 --- a/insights/fetcher.go +++ b/insights/fetcher.go @@ -29,10 +29,10 @@ func newFetcher(pool *dbconn.RoPool) (*fetcher, error) { type creator struct { *core.DBCreator - notifier notification.Center + notifier *notification.Center } -func newCreator(conn dbconn.RwConn, notifier notification.Center) (*creator, error) { +func newCreator(conn dbconn.RwConn, notifier *notification.Center) (*creator, error) { c, err := core.NewCreator(conn) if err != nil { @@ -49,10 +49,8 @@ func (c *creator) GenerateInsight(tx *sql.Tx, properties core.InsightProperties) return errorutil.Wrap(err) } - if properties.Rating == core.BadRating { - if err := c.notifier.Notify(notification.Notification{ID: id, Content: properties.Content}); err != nil { - return errorutil.Wrap(err) - } + if err := c.notifier.Notify(notification.Notification{ID: id, Content: properties}); err != nil { + return errorutil.Wrap(err) } return nil diff --git a/insights/insights_test.go b/insights/insights_test.go index 11eb548e3ebc8be2aa54016f5c2b65f503da1f90..b7c98f720801f13cbcf56893e5696d2e88d7fe23 100644 --- a/insights/insights_test.go +++ b/insights/insights_test.go @@ -10,13 +10,15 @@ import ( "database/sql" . "github.com/smartystreets/goconvey/convey" "gitlab.com/lightmeter/controlcenter/data" + "gitlab.com/lightmeter/controlcenter/i18n/translator" "gitlab.com/lightmeter/controlcenter/insights/core" insighttestsutil "gitlab.com/lightmeter/controlcenter/insights/testutil" "gitlab.com/lightmeter/controlcenter/lmsqlite3" "gitlab.com/lightmeter/controlcenter/lmsqlite3/dbconn" "gitlab.com/lightmeter/controlcenter/notification" - "gitlab.com/lightmeter/controlcenter/settings" "gitlab.com/lightmeter/controlcenter/util/testutil" + "golang.org/x/text/language" + "golang.org/x/text/message/catalog" "sync" "testing" "time" @@ -46,16 +48,22 @@ func (c content) Args() []interface{} { return nil } -type fakeNotificationCenter struct { +type fakeNotifier struct { notifications []notification.Notification } -func (f *fakeNotificationCenter) Notify(n notification.Notification) error { - f.notifications = append(f.notifications, n) - return nil -} +func (f *fakeNotifier) Notify(n notification.Notification, _ translator.Translator) error { + pass, err := DefaultNotificationPolicy{}.Pass(n) + + if err != nil { + return err + } -func (f *fakeNotificationCenter) AddSlackNotifier(notificationsSettings settings.SlackNotificationsSettings) error { + if !pass { + return nil + } + + f.notifications = append(f.notifications, n) return nil } @@ -138,9 +146,13 @@ func TestEngine(t *testing.T) { dir, clearDir := testutil.TempDir(t) defer clearDir() - nc := &fakeNotificationCenter{} + notifier := &fakeNotifier{} - detector := &fakeDetector{t:t} + nc := notification.NewWithCustomLanguageFetcher(translator.New(catalog.NewBuilder()), func() (language.Tag, error) { + return language.English, nil + }, []notification.Notifier{notifier}) + + detector := &fakeDetector{t: t} noAdditionalActions := func([]core.Detector, dbconn.RwConn) error { return nil } @@ -188,7 +200,7 @@ func TestEngine(t *testing.T) { genInsight(fakeValue{Category: core.LocalCategory, Content: content{"42"}, Rating: core.BadRating}) nopStep() nopStep() - genInsight(fakeValue{Category: core.IntelCategory, Content: content{"35"}, Rating: core.BadRating}) + genInsight(fakeValue{Category: core.IntelCategory, Content: content{"35"}, Rating: core.OkRating}) nopStep() genInsight(fakeValue{Category: core.ComparativeCategory, Content: content{"13"}, Rating: core.BadRating}) @@ -199,11 +211,22 @@ func TestEngine(t *testing.T) { So(ok, ShouldBeTrue) - So(nc.notifications, ShouldResemble, []notification.Notification{ - {ID: 1, Content: content{"42"}}, - {ID: 2, Content: content{"35"}}, - {ID: 3, Content: content{"13"}}, - }) + // Notify only bad-rating insights + So(len(notifier.notifications), ShouldEqual, 2) + + { + n, ok := notifier.notifications[0].Content.(core.InsightProperties) + So(ok, ShouldBeTrue) + So(notifier.notifications[0].ID, ShouldEqual, 1) + So(n.Content, ShouldResemble, content{"42"}) + } + + { + n, ok := notifier.notifications[1].Content.(core.InsightProperties) + So(ok, ShouldBeTrue) + So(notifier.notifications[1].ID, ShouldEqual, 3) + So(n.Content, ShouldResemble, content{"13"}) + } fetcher := e.Fetcher() @@ -228,7 +251,7 @@ func TestEngine(t *testing.T) { So(insights[1].Category(), ShouldEqual, core.IntelCategory) So(insights[1].Content().(*content).V, ShouldEqual, "35") So(insights[1].ID(), ShouldEqual, 2) - So(insights[1].Rating(), ShouldEqual, core.BadRating) + So(insights[1].Rating(), ShouldEqual, core.OkRating) So(insights[1].Time(), ShouldEqual, testutil.MustParseTime(`2000-01-01 00:00:05 +0000`)) So(insights[2].Category(), ShouldEqual, core.LocalCategory) @@ -260,7 +283,7 @@ func TestEngine(t *testing.T) { So(insights[1].Category(), ShouldEqual, core.IntelCategory) So(insights[1].Content().(*content).V, ShouldEqual, "35") So(insights[1].ID(), ShouldEqual, 2) - So(insights[1].Rating(), ShouldEqual, core.BadRating) + So(insights[1].Rating(), ShouldEqual, core.OkRating) So(insights[1].Time(), ShouldEqual, testutil.MustParseTime(`2000-01-01 00:00:05 +0000`)) }) @@ -286,7 +309,7 @@ func TestEngine(t *testing.T) { So(insights[1].Category(), ShouldEqual, core.IntelCategory) So(insights[1].Content().(*content).V, ShouldEqual, "35") So(insights[1].ID(), ShouldEqual, 2) - So(insights[1].Rating(), ShouldEqual, core.BadRating) + So(insights[1].Rating(), ShouldEqual, core.OkRating) So(insights[1].Time(), ShouldEqual, testutil.MustParseTime(`2000-01-01 00:00:05 +0000`)) So(insights[2].Category(), ShouldEqual, core.ComparativeCategory) @@ -314,7 +337,7 @@ func TestEngine(t *testing.T) { So(insights[0].Category(), ShouldEqual, core.IntelCategory) So(insights[0].Content().(*content).V, ShouldEqual, "35") So(insights[0].ID(), ShouldEqual, 2) - So(insights[0].Rating(), ShouldEqual, core.BadRating) + So(insights[0].Rating(), ShouldEqual, core.OkRating) So(insights[0].Time(), ShouldEqual, testutil.MustParseTime(`2000-01-01 00:00:05 +0000`)) }) }) @@ -367,9 +390,11 @@ func TestEngine(t *testing.T) { cancel() done() - So(nc.notifications, ShouldResemble, []notification.Notification{ - {ID: 1, Content: content{"content"}}, - }) + So(len(notifier.notifications), ShouldEqual, 1) + + n, ok := notifier.notifications[0].Content.(core.InsightProperties) + So(ok, ShouldBeTrue) + So(n.Content, ShouldResemble, content{"content"}) }) }) } diff --git a/insights/notification_policies.go b/insights/notification_policies.go new file mode 100644 index 0000000000000000000000000000000000000000..94d8824e0034197560b7a3acfbc341e132a4b018 --- /dev/null +++ b/insights/notification_policies.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2021 Lightmeter +// +// SPDX-License-Identifier: AGPL-3.0-only + +package insights + +import ( + "gitlab.com/lightmeter/controlcenter/insights/core" + "gitlab.com/lightmeter/controlcenter/notification" +) + +type DefaultNotificationPolicy struct { +} + +func (DefaultNotificationPolicy) Pass(n notification.Notification) (bool, error) { + p, ok := n.Content.(core.InsightProperties) + return ok && p.Rating == core.BadRating, nil +} diff --git a/notification/bus/bus.go b/notification/bus/bus.go deleted file mode 100644 index 26fa3e28494615ff77063bff4c0d489f11cb7f4a..0000000000000000000000000000000000000000 --- a/notification/bus/bus.go +++ /dev/null @@ -1,147 +0,0 @@ -// SPDX-FileCopyrightText: 2021 Lightmeter -// -// SPDX-License-Identifier: AGPL-3.0-only - -package bus - -import ( - "errors" - "fmt" - "gitlab.com/lightmeter/controlcenter/util/errorutil" - "reflect" - "sync" -) - -// The type of the function's first and only argument -// declares the msg to listen for. -type HandlerFunc interface{} - -type Msg interface{} - -// It is a simple but powerful publish-subscribe event system. It requires object to -// register themselves with the event bus to receive events. -type Interface interface { - AddEventListener(typ interface{}, handler HandlerFunc) - UpdateEventListener(typ interface{}, handler HandlerFunc) - Publish(msg Msg) error -} - -type bus struct { - listeners *sync.Map - indexies *sync.Map - isInit bool -} - -func New() Interface { - return &bus{ - listeners: new(sync.Map), - indexies: new(sync.Map), - } -} - -var ErrNoListeners = errors.New("listeners aren't registered") - -// Publish sends an msg to all registered listeners that were declared -// to accept values of a msg -func (b *bus) Publish(msg Msg) error { - if !b.isInit { - return errorutil.Wrap(ErrNoListeners) - } - - nameOfMsg := reflect.TypeOf(msg) - - val, ok := b.listeners.Load(nameOfMsg.String()) - if !ok { - return nil - } - - listeners := val.([]reflect.Value) - - params := make([]reflect.Value, 0, 1) - params = append(params, reflect.ValueOf(msg)) - - for _, listenerHandler := range listeners { - ret := listenerHandler.Call(params) - v := ret[0].Interface() - - if err, ok := v.(error); ok && err != nil { - return errorutil.Wrap(err) - } - } - - return nil -} - -// AddListener registers a listener function that will be called when a matching -// msg is dispatched. -// it isn't allowed to register two handlers with same typ -func (b *bus) AddEventListener(typ interface{}, handler HandlerFunc) { - b.isInit = true - - _, ok := b.indexies.Load(typ) - if ok { - panic("handler of typ is registered") - } - - handlerType := reflect.TypeOf(handler) - validateHandlerFunc(handlerType) - // the first input parameter is the msg - typOfMsg := handlerType.In(0) - - listeners := make([]reflect.Value, 0) - - val, ok := b.listeners.Load(typOfMsg.String()) - if ok { - listeners = val.([]reflect.Value) - } - - listeners = append(listeners, reflect.ValueOf(handler)) - b.listeners.Store(typOfMsg.String(), listeners) - b.indexies.Store(typ, len(listeners)-1) -} - -// UpdateEventListener updates a listener function that will be called when a matching -// msg is dispatched. -func (b *bus) UpdateEventListener(typ interface{}, handler HandlerFunc) { - b.isInit = true - - handlerType := reflect.TypeOf(handler) - validateHandlerFunc(handlerType) - // the first input parameter is the msg - typOfMsg := handlerType.In(0) - - val, ok := b.indexies.Load(typ) - if !ok { - listeners := []reflect.Value{reflect.ValueOf(handler)} - b.listeners.Store(typOfMsg.String(), listeners) - b.indexies.Store(typ, len(listeners)-1) - - return - } - - index := val.(int) - val, _ = b.listeners.Load(typOfMsg.String()) - listeners := val.([]reflect.Value) - listeners[index] = reflect.ValueOf(handler) - b.listeners.Store(typOfMsg.String(), listeners) -} - -// panic if conditions not met (this is a programming error) -func validateHandlerFunc(handlerType reflect.Type) { - switch { - case handlerType.Kind() != reflect.Func: - panic(BadFuncError("handler func must be a function")) - case handlerType.NumIn() != 1: - panic(BadFuncError("handler func must take exactly one input argument")) - case handlerType.NumOut() != 1: - panic(BadFuncError("handler func must take exactly one output argument")) - } -} - -// BadFuncError is raised via panic() when AddEventListener or AddHandler is called with an -// invalid listener function. -type BadFuncError string - -func (bhf BadFuncError) Error() string { - return fmt.Sprintf("bad handler func: %s", string(bhf)) -} diff --git a/notification/bus/bus_test.go b/notification/bus/bus_test.go deleted file mode 100644 index 118a94b56ca1aa9401392d8dc5fa174e88307092..0000000000000000000000000000000000000000 --- a/notification/bus/bus_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-FileCopyrightText: 2021 Lightmeter -// -// SPDX-License-Identifier: AGPL-3.0-only - -package bus_test - -import ( - . "github.com/smartystreets/goconvey/convey" - "gitlab.com/lightmeter/controlcenter/notification/bus" - "sync/atomic" - "testing" -) - -func TestBus(t *testing.T) { - - Convey("Bus", t, func() { - - bus := bus.New() - - Convey("Flow", func() { - - var counter int32 - bus.AddEventListener("kind1", func(msg string) error { - t.Log("AddEventListener") - t.Log(msg) - atomic.AddInt32(&counter, 1) - return nil - }) - - bus.AddEventListener("kind2", func(msg string) error { - t.Log("AddEventListener") - t.Log(msg) - atomic.AddInt32(&counter, 1) - return nil - }) - - err := bus.Publish("Hello world") - So(err, ShouldBeNil) - So(atomic.LoadInt32(&counter), ShouldEqual, 2) - - // Reset counter - atomic.AddInt32(&counter, -2) - - bus.UpdateEventListener("kind1", func(msg string) error { - t.Log("UpdateEventListener") - t.Log(msg) - atomic.AddInt32(&counter, 1) - return nil - }) - err = bus.Publish("Hello world") - So(err, ShouldBeNil) - - So(atomic.LoadInt32(&counter), ShouldEqual, 2) - - }) - }) -} - -func TestBusPanic(t *testing.T) { - - Convey("Bus", t, func() { - - bus := bus.New() - - Convey("Flow panic", func() { - So(func() { - bus.AddEventListener("kind1", func(msg string) error { - t.Log("AddEventListener") - t.Log(msg) - return nil - }) - - bus.AddEventListener("kind1", func(msg string) error { - t.Log("AddEventListener") - t.Log(msg) - return nil - }) - }, ShouldPanic) - }) - }) -} diff --git a/notification/core/notifier.go b/notification/core/notifier.go new file mode 100644 index 0000000000000000000000000000000000000000..b7994b7b745014eac443e2b138485d0e3e9c4712 --- /dev/null +++ b/notification/core/notifier.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2021 Lightmeter +// +// SPDX-License-Identifier: AGPL-3.0-only + +package core + +import ( + "fmt" + "gitlab.com/lightmeter/controlcenter/i18n/translator" +) + +// TODO: a notifier should be notified asynchronously!!! +type Notifier interface { + Notify(Notification, translator.Translator) error +} + +type Notification struct { + ID int64 + Content Content + Rating int64 +} + +type Content interface { + fmt.Stringer + translator.TranslatableStringer +} diff --git a/notification/core/policy.go b/notification/core/policy.go new file mode 100644 index 0000000000000000000000000000000000000000..59305d2860ff8d4ad75334f660e0b6bcdcff7c62 --- /dev/null +++ b/notification/core/policy.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2021 Lightmeter +// +// SPDX-License-Identifier: AGPL-3.0-only + +package core + +import ( + "gitlab.com/lightmeter/controlcenter/util/errorutil" +) + +type Policy interface { + // Can a notification be notified according to this policy? + Pass(Notification) (bool, error) +} + +type Policies []Policy + +func (policies Policies) Pass(n Notification) (bool, error) { + for _, p := range policies { + pass, err := p.Pass(n) + + if err != nil { + return false, errorutil.Wrap(err) + } + + if pass { + return true, nil + } + } + + return false, nil +} diff --git a/notification/core/translate.go b/notification/core/translate.go new file mode 100644 index 0000000000000000000000000000000000000000..38987020ca75c7d879cde77be1dfccfc651d64c1 --- /dev/null +++ b/notification/core/translate.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2021 Lightmeter +// +// SPDX-License-Identifier: AGPL-3.0-only + +package core + +import ( + "fmt" + "gitlab.com/lightmeter/controlcenter/i18n/translator" + "gitlab.com/lightmeter/controlcenter/util/errorutil" +) + +func Messagef(format string, a ...interface{}) Message { + return Message(fmt.Sprintf(format, a...)) +} + +type Message string + +func (s *Message) String() string { + return string(*s) +} + +func TranslateNotification(notification Notification, t translator.Translator) (Message, error) { + transformed := translator.TransformTranslation(notification.Content.TplString()) + + translatedMessage, err := t.Translate(transformed) + if err != nil { + return "", errorutil.Wrap(err) + } + + args := notification.Content.Args() + + // TODO: restore this, or better, rely on the translator! + // for i, arg := range args { + // t, ok := arg.(time.Time) + // if ok { + // args[i] = timeutil.PrettyFormatTime(t, language) + // } + // } + + return Messagef(translatedMessage, args...), nil +} diff --git a/notification/notification_test.go b/notification/notification_test.go index 82609492c22eb6583f0818234ad7870cac9cd868..02f96b6e483504c272c3fc3938b808839e1838cb 100644 --- a/notification/notification_test.go +++ b/notification/notification_test.go @@ -6,15 +6,16 @@ package notification import ( "context" + "errors" "fmt" + slackAPI "github.com/slack-go/slack" . "github.com/smartystreets/goconvey/convey" "gitlab.com/lightmeter/controlcenter/i18n/translator" "gitlab.com/lightmeter/controlcenter/lmsqlite3" - "gitlab.com/lightmeter/controlcenter/meta" + "gitlab.com/lightmeter/controlcenter/notification/core" + "gitlab.com/lightmeter/controlcenter/notification/slack" "gitlab.com/lightmeter/controlcenter/po" "gitlab.com/lightmeter/controlcenter/settings" - "gitlab.com/lightmeter/controlcenter/util/errorutil" - "gitlab.com/lightmeter/controlcenter/util/testutil" "golang.org/x/text/language" "golang.org/x/text/message/catalog" "sync/atomic" @@ -51,78 +52,98 @@ func (c fakeContent) Args() []interface{} { return []interface{}{c.Interval.From, c.Interval.To} } -func TestSendNotification(t *testing.T) { +type dummyPolicy struct { +} - Convey("Notification", t, func() { - conn, closeConn := testutil.TempDBConnection(t) - defer closeConn() +func (dummyPolicy) Pass(core.Notification) (bool, error) { + return true, nil +} - m, err := meta.NewHandler(conn, "master") - So(err, ShouldBeNil) +type fakeSlackPoster struct { + err error +} - runner := meta.NewRunner(m) - done, cancel := runner.Run() - defer func() { cancel(); done() }() - writer := runner.Writer() +var fakeSlackError = errors.New(`Some Slack Error`) - defer func() { errorutil.MustSucceed(m.Close()) }() +func (p *fakeSlackPoster) PostMessage(channelID string, options ...slackAPI.MsgOption) (string, string, error) { + return "", "", p.err +} +func centerWithTranslatorsAndDummyPolicy(t *testing.T, translators translator.Translators, slackSettings *settings.SlackNotificationsSettings) *Center { + notifiers := func() []core.Notifier { + if slackSettings == nil { + return []core.Notifier{} + } + + slackNotifier := slack.NewWithCustomSettingsFetcher(core.Policies{&dummyPolicy{}}, func() (*settings.SlackNotificationsSettings, error) { + return slackSettings, nil + }) + + // don't use slack api, mocking the PostMessage call + slackNotifier.MessagePosterBuilder = func(client *slackAPI.Client) slack.MessagePoster { + return &fakeSlackPoster{} + } + + return []core.Notifier{slackNotifier} + }() + + center := NewWithCustomLanguageFetcher(translators, func() (language.Tag, error) { + if slackSettings != nil { + return language.Parse(slackSettings.Language) + } + + return language.English, nil + }, notifiers) + + return center +} + +func buildSlackSettings(lang string, enabled bool) settings.SlackNotificationsSettings { + return settings.SlackNotificationsSettings{ + Channel: "general", + Kind: "slack", + BearerToken: "some_slack_key", + Enabled: enabled, + Language: lang, + } +} + +func TestSendNotification(t *testing.T) { + Convey("Notification", t, func() { content := new(fakeContent) content.Interval.To = time.Now() content.Interval.From = time.Now() Convey("Success", func() { Convey("Do subscribe (german)", func() { - - slackSettings := settings.SlackNotificationsSettings{ - Channel: "general", - Kind: "slack", - BearerToken: "xoxb-1388191062644-1385067635637-iXfDIfcPO3HKHEjLZY2seVX6", - Enabled: true, - Language: "de", - } - - err = settings.SetSlackNotificationsSettings(dummyContext, writer, slackSettings) - So(err, ShouldBeNil) - - DefaultCatalog := catalog.NewBuilder() + cat := catalog.NewBuilder() lang := language.MustParse("de") - DefaultCatalog.SetString(lang, content.TplString(), `Zwischen %v und %v wurden keine E-Mails gesendet`) + cat.SetString(lang, content.TplString(), `Zwischen %v und %v wurden keine E-Mails gesendet`) - translators := translator.New(DefaultCatalog) - center := New(m.Reader, translators) - So(err, ShouldBeNil) + translators := translator.New(cat) + s := buildSlackSettings("de", true) + + center := centerWithTranslatorsAndDummyPolicy(t, translators, &s) - notification := Notification{ + notification := core.Notification{ ID: 0, Content: content, } + err := center.Notify(notification) So(err, ShouldBeNil) }) Convey("Do subscribe (english)", func() { - - slackSettings := settings.SlackNotificationsSettings{ - Channel: "general", - Kind: "slack", - BearerToken: "xoxb-1388191062644-1385067635637-iXfDIfcPO3HKHEjLZY2seVX6", - Enabled: true, - Language: "en", - } - - err = settings.SetSlackNotificationsSettings(dummyContext, writer, slackSettings) - So(err, ShouldBeNil) - - DefaultCatalog := catalog.NewBuilder() + cat := catalog.NewBuilder() lang := language.MustParse("en") - DefaultCatalog.SetString(lang, content.TplString(), content.TplString()) + cat.SetString(lang, content.TplString(), content.TplString()) - translators := translator.New(DefaultCatalog) - center := New(m.Reader, translators) - So(err, ShouldBeNil) + translators := translator.New(cat) + s := buildSlackSettings("en", true) + center := centerWithTranslatorsAndDummyPolicy(t, translators, &s) - notification := Notification{ + notification := core.Notification{ ID: 0, Content: content, } @@ -132,27 +153,15 @@ func TestSendNotification(t *testing.T) { }) Convey("Do subscribe (pt_BR)", func() { - - slackSettings := settings.SlackNotificationsSettings{ - Channel: "general", - Kind: "slack", - BearerToken: "xoxb-1388191062644-1385067635637-iXfDIfcPO3HKHEjLZY2seVX6", - Enabled: true, - Language: "pt_BR", - } - - err = settings.SetSlackNotificationsSettings(dummyContext, writer, slackSettings) - So(err, ShouldBeNil) - - DefaultCatalog := catalog.NewBuilder() + cat := catalog.NewBuilder() lang := language.MustParse("pt_BR") - DefaultCatalog.SetString(lang, content.TplString(), content.TplString()) + cat.SetString(lang, content.TplString(), content.TplString()) - translators := translator.New(DefaultCatalog) - center := New(m.Reader, translators) - So(err, ShouldBeNil) + translators := translator.New(cat) + s := buildSlackSettings("pt_BR", true) + center := centerWithTranslatorsAndDummyPolicy(t, translators, &s) - notification := Notification{ + notification := core.Notification{ ID: 0, Content: content, } @@ -165,23 +174,12 @@ func TestSendNotification(t *testing.T) { } func TestSendNotificationMissingConf(t *testing.T) { - Convey("Notification", t, func() { - conn, closeConn := testutil.TempDBConnection(t) - defer closeConn() - - m, err := meta.NewHandler(conn, "master") - So(err, ShouldBeNil) - - defer func() { errorutil.MustSucceed(m.Close()) }() - translators := translator.New(po.DefaultCatalog) - center := New(m.Reader, translators) - - So(err, ShouldBeNil) + center := centerWithTranslatorsAndDummyPolicy(t, translators, nil) content := new(fakeContent) - notification := Notification{ + notification := core.Notification{ ID: 0, Content: content, } @@ -196,115 +194,39 @@ func TestSendNotificationMissingConf(t *testing.T) { } type fakeapi struct { - t *testing.T + t *testing.T Counter int32 } -func (s *fakeapi) PostMessage(stringer Message) error { - s.t.Log(stringer) +func (s *fakeapi) Notify(n core.Notification, _ translator.Translator) error { + s.t.Log(n) atomic.AddInt32(&s.Counter, 1) return nil } func TestFakeSendNotification(t *testing.T) { - Convey("Notification", t, func() { - conn, closeConn := testutil.TempDBConnection(t) - defer closeConn() - - m, err := meta.NewHandler(conn, "master") - So(err, ShouldBeNil) - - runner := meta.NewRunner(m) - done, cancel := runner.Run() - defer func() { cancel(); done() }() - writer := runner.Writer() - - defer func() { errorutil.MustSucceed(m.Close()) }() - - slackSettings := settings.SlackNotificationsSettings{ - Channel: "general", - Kind: "slack", - BearerToken: "xoxb-1388191062644-1385067635637-iXfDIfcPO3HKHEjLZY2seVX6", - Enabled: true, - Language: "de", - } - - err = settings.SetSlackNotificationsSettings(dummyContext, writer, slackSettings) - So(err, ShouldBeNil) - fakeapi := &fakeapi{t: t} - DefaultCatalog := catalog.NewBuilder() + cat := catalog.NewBuilder() lang := language.MustParse("de") - DefaultCatalog.SetString(lang, `%v bounce rate between %v and %v`, `%v bounce rate ist zwischen %v und %v`) - translators := translator.New(DefaultCatalog) + cat.SetString(lang, `%v bounce rate between %v and %v`, `%v bounce rate ist zwischen %v und %v`) + translators := translator.New(cat) - centerInterface := New(m.Reader, translators) - c := centerInterface.(*center) - c.slackapi = fakeapi + center := NewWithCustomLanguageFetcher(translators, func() (language.Tag, error) { return language.German, nil }, []core.Notifier{fakeapi}) content := new(fakeContent) - Notification := Notification{ - ID: 0, - Content: content, - } - Convey("Success", func() { - Convey("Do subscribe", func() { - err := c.Notify(Notification) - So(err, ShouldBeNil) - So(fakeapi.Counter, ShouldEqual, 1) - }) - }) - }) -} - -func TestFakeSendNotificationDisabled(t *testing.T) { - - Convey("Notification", t, func() { - conn, closeConn := testutil.TempDBConnection(t) - defer closeConn() - - m, err := meta.NewHandler(conn, "master") - So(err, ShouldBeNil) - - runner := meta.NewRunner(m) - done, cancel := runner.Run() - defer func() { cancel(); done() }() - writer := runner.Writer() - - defer func() { errorutil.MustSucceed(m.Close()) }() - - slackSettings := settings.SlackNotificationsSettings{ - Channel: "general", - Kind: "slack", - BearerToken: "xoxb-1388191062644-1385067635637-iXfDIfcPO3HKHEjLZY2seVX6", - Enabled: false, - Language: "en", - } - - err = settings.SetSlackNotificationsSettings(dummyContext, writer, slackSettings) - So(err, ShouldBeNil) - - fakeapi := &fakeapi{} - translators := translator.New(po.DefaultCatalog) - centerInterface := New(m.Reader, translators) - - c := centerInterface.(*center) - c.slackapi = fakeapi - - content := new(fakeContent) - Notification := Notification{ + notification := core.Notification{ ID: 0, Content: content, } Convey("Success", func() { Convey("Do subscribe", func() { - err := c.Notify(Notification) + err := center.Notify(notification) So(err, ShouldBeNil) - So(fakeapi.Counter, ShouldEqual, 0) + So(fakeapi.Counter, ShouldEqual, 1) }) }) }) diff --git a/notification/notifications.go b/notification/notifications.go index 338e7fb95a0421b68613a17804a74045623ce9be..f9947326fcf4b5dae25927fd8e5b904df551df50 100644 --- a/notification/notifications.go +++ b/notification/notifications.go @@ -6,202 +6,75 @@ package notification import ( "context" - "errors" - "fmt" - "github.com/slack-go/slack" + "github.com/rs/zerolog/log" "gitlab.com/lightmeter/controlcenter/i18n/translator" "gitlab.com/lightmeter/controlcenter/meta" - "gitlab.com/lightmeter/controlcenter/notification/bus" + "gitlab.com/lightmeter/controlcenter/notification/core" "gitlab.com/lightmeter/controlcenter/settings" "gitlab.com/lightmeter/controlcenter/util/errorutil" - "gitlab.com/lightmeter/controlcenter/util/timeutil" "golang.org/x/text/language" - "time" ) -type Content interface { - fmt.Stringer - translator.TranslatableStringer -} +type ( + Notification = core.Notification + Notifier = core.Notifier + Policy = core.Policy + Policies = core.Policies +) -type Notification struct { - ID int64 - Content Content - Rating int64 -} +type alwaysAllowPolicy struct{} -type Center interface { - Notify(Notification) error - AddSlackNotifier(notificationsSettings settings.SlackNotificationsSettings) error +func (alwaysAllowPolicy) Pass(core.Notification) (bool, error) { + return true, nil } -func New(settingsReader *meta.Reader, translators translator.Translators) Center { - cp := ¢er{ - bus: bus.New(), - settingsReader: settingsReader, - translators: translators, - } +var AlwaysAllowPolicies = core.Policies{alwaysAllowPolicy{}} - if err := cp.init(); err != nil { - errorutil.LogErrorf(err, "init notifications") +func NewWithCustomLanguageFetcher(translators translator.Translators, languageFetcher func() (language.Tag, error), notifiers []core.Notifier) *Center { + return &Center{ + translators: translators, + notifiers: notifiers, + fetchLanguage: languageFetcher, } - - return cp -} - -type center struct { - bus bus.Interface - settingsReader *meta.Reader - slackapi Messenger - translators translator.Translators } -func (cp *center) init() error { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) - - defer cancel() - - slackSettings, err := settings.GetSlackNotificationsSettings(ctx, cp.settingsReader) - if err != nil { - if errors.Is(err, meta.ErrNoSuchKey) { - return nil - } - - return errorutil.Wrap(err) - } - - if !slackSettings.Enabled { - return nil - } - - languageTag, err := language.Parse(slackSettings.Language) - if err != nil { - return errorutil.Wrap(err) - } - - cp.slackapi = newSlack(slackSettings.BearerToken, slackSettings.Channel) - translator := cp.translators.Translator(languageTag, time.Time{}) - - err = cp.slackapi.PostMessage(newConnectContent()) - if err != nil { - return errorutil.Wrap(err) - } - - cp.bus.AddEventListener("slack", func(notification Notification) error { - translatedMessage, args, err := cp.Translate(slackSettings.Language, translator, notification) +func New(reader *meta.Reader, translators translator.Translators, notifiers []core.Notifier) *Center { + return NewWithCustomLanguageFetcher(translators, func() (language.Tag, error) { + // TODO: get the settings from a "Notifications general settings" separated from Slack + settings, err := settings.GetSlackNotificationsSettings(context.Background(), reader) if err != nil { - return errorutil.Wrap(err) + return language.Tag{}, errorutil.Wrap(err) } - return cp.slackapi.PostMessage(Messagef(translatedMessage, args...)) - }) - return nil -} - -func newConnectContent() Message { - return "Lightmeter ControlCenter successfully connected to Slack!" -} + tag, err := language.Parse(settings.Language) -func (cp *center) AddSlackNotifier(slackSettings settings.SlackNotificationsSettings) error { - cp.slackapi = newSlack(slackSettings.BearerToken, slackSettings.Channel) - - if slackSettings.Enabled { - err := cp.slackapi.PostMessage(newConnectContent()) if err != nil { - return errorutil.Wrap(err) + return language.Tag{}, errorutil.Wrap(err) } - } - - languageTag := language.MustParse(slackSettings.Language) - - cp.slackapi = newSlack(slackSettings.BearerToken, slackSettings.Channel) - translator := cp.translators.Translator(languageTag, time.Time{}) - cp.bus.UpdateEventListener("slack", func(notification Notification) error { - if !slackSettings.Enabled { - return nil - } - - translatedMessage, args, err := cp.Translate(slackSettings.Language, translator, notification) - if err != nil { - return errorutil.Wrap(err) - } - - return cp.slackapi.PostMessage(Messagef(translatedMessage, args...)) - }) - - return nil + return tag, nil + }, notifiers) } -func (cp *center) Translate(language string, t translator.Translator, notification Notification) (string, []interface{}, error) { - transformed := translator.TransformTranslation(notification.Content.TplString()) - - translatedMessage, err := t.Translate(transformed) - if err != nil { - return "", nil, errorutil.Wrap(err) - } - - args := notification.Content.Args() - for i, arg := range args { - t, ok := arg.(time.Time) - if ok { - args[i] = timeutil.PrettyFormatTime(t, language) - } - } - - return translatedMessage, args, nil +type Center struct { + translators translator.Translators + notifiers []core.Notifier + fetchLanguage func() (language.Tag, error) } -func (cp *center) Notify(notification Notification) error { - err := cp.bus.Publish(notification) +func (c *Center) Notify(notification core.Notification) error { + languageTag, err := c.fetchLanguage() if err != nil { - if errors.Is(err, bus.ErrNoListeners) { - return nil - } - return errorutil.Wrap(err) } - return nil -} - -func Messagef(format string, a ...interface{}) Message { - return Message(fmt.Sprintf(format, a...)) -} - -type Message string - -func (s *Message) String() string { - return string(*s) -} - -type Messenger interface { - PostMessage(stringer Message) error -} - -func newSlack(token string, channel string) Messenger { - client := slack.New(token) - - return &slackapi{ - client: client, - channel: channel, - } -} - -type slackapi struct { - client *slack.Client - channel string -} + translator := c.translators.Translator(languageTag, time.Time{}) -func (s *slackapi) PostMessage(message Message) error { - _, _, err := s.client.PostMessage( - s.channel, - slack.MsgOptionText(message.String(), false), - slack.MsgOptionAsUser(true), - ) - if err != nil { - return errorutil.Wrap(err) + for _, n := range c.notifiers { + if err := n.Notify(notification, translator); err != nil { + log.Warn().Msgf("Error notifying: (%v): %v", n, err) + } } return nil diff --git a/notification/slack/slack.go b/notification/slack/slack.go new file mode 100644 index 0000000000000000000000000000000000000000..bc1d864b2fe3830bc181589eadc11c7e03574d2b --- /dev/null +++ b/notification/slack/slack.go @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2021 Lightmeter +// +// SPDX-License-Identifier: AGPL-3.0-only + +package slack + +import ( + "context" + "github.com/slack-go/slack" + "gitlab.com/lightmeter/controlcenter/i18n/translator" + "gitlab.com/lightmeter/controlcenter/meta" + "gitlab.com/lightmeter/controlcenter/notification/core" + "gitlab.com/lightmeter/controlcenter/settings" + "gitlab.com/lightmeter/controlcenter/util/errorutil" + "reflect" + "sync" +) + +// TODO: make the notifications asynchronous! +// Add context to PostMessage and to slack api call! + +const SettingKey = "messenger_slack" + +type MessagePoster interface { + PostMessage(channelID string, options ...slack.MsgOption) (string, string, error) +} + +func messagePosterBuilder(client *slack.Client) MessagePoster { + return client +} + +type Notifier struct { + // this mutex protects the access to the settings and the slack api client + clientMutex sync.Mutex + client *slack.Client + currentSettings *settings.SlackNotificationsSettings + + fetchSettings func() (*settings.SlackNotificationsSettings, error) + policies core.Policies + + MessagePosterBuilder func(client *slack.Client) MessagePoster +} + +func New(policies core.Policies, reader *meta.Reader) *Notifier { + return NewWithCustomSettingsFetcher(policies, func() (*settings.SlackNotificationsSettings, error) { + s := settings.SlackNotificationsSettings{} + + if err := reader.RetrieveJson(context.Background(), SettingKey, &s); err != nil { + return nil, errorutil.Wrap(err) + } + + return &s, nil + }) +} + +func NewWithCustomSettingsFetcher(policies core.Policies, settingsFetcher func() (*settings.SlackNotificationsSettings, error)) *Notifier { + return &Notifier{ + fetchSettings: settingsFetcher, + policies: policies, + MessagePosterBuilder: messagePosterBuilder, + } +} + +func clientAndSettingsForMessenger(m *Notifier) (*slack.Client, *settings.SlackNotificationsSettings, error) { + updatedSettings, err := m.fetchSettings() + + if err != nil { + return nil, nil, errorutil.Wrap(err) + } + + if updatedSettings == nil { + panic("slack setting cannot be nil and this is a bug in your code!") + } + + m.clientMutex.Lock() + + defer m.clientMutex.Unlock() + + // update/create client if needed + if m.currentSettings == nil || !reflect.DeepEqual(*updatedSettings, *m.currentSettings) { + m.client = slack.New(updatedSettings.BearerToken) + m.currentSettings = updatedSettings + } + + return m.client, updatedSettings, nil +} + +func (m *Notifier) SendTestNotification() error { + if err := tryToNotifyMessage(m, core.Message("Lightmeter ControlCenter successfully connected to Slack!")); err != nil { + return errorutil.Wrap(err) + } + + return nil +} + +func tryToNotifyMessage(m *Notifier, message core.Message) error { + client, settings, err := clientAndSettingsForMessenger(m) + if err != nil { + return errorutil.Wrap(err) + } + + if !settings.Enabled { + return nil + } + + poster := m.MessagePosterBuilder(client) + + _, _, err = poster.PostMessage(settings.Channel, slack.MsgOptionText(message.String(), false), slack.MsgOptionAsUser(true)) + if err != nil { + return errorutil.Wrap(err) + } + + return nil +} + +func (m *Notifier) DeriveNotifierWithCustomSettingsFetcher(policies core.Policies, settingsFetcher func() (*settings.SlackNotificationsSettings, error)) *Notifier { + return &Notifier{ + fetchSettings: settingsFetcher, + policies: policies, + MessagePosterBuilder: m.MessagePosterBuilder, + } +} + +// implement Notifier +func (m *Notifier) Notify(n core.Notification, translator translator.Translator) error { + pass, err := m.policies.Pass(n) + if err != nil { + return errorutil.Wrap(err) + } + + if !pass { + return nil + } + + message, err := core.TranslateNotification(n, translator) + if err != nil { + return errorutil.Wrap(err) + } + + if err := tryToNotifyMessage(m, message); err != nil { + return errorutil.Wrap(err) + } + + return nil +} diff --git a/server/server.go b/server/server.go index e299663a81ebf93a6799ee26298e898b99dcd9a5..2630c0964ee33eddb1d626031245a62020506c52 100644 --- a/server/server.go +++ b/server/server.go @@ -53,7 +53,7 @@ func (s *HttpServer) Start() error { writer, reader := s.Workspace.SettingsAcessors() - setup := httpsettings.NewSettings(writer, reader, initialSetupSettings, s.Workspace.NotificationCenter) + setup := httpsettings.NewSettings(writer, reader, initialSetupSettings, s.Workspace.NotificationCenter, s.Workspace.SlackNotifier) auth := auth.NewAuthenticator(s.Workspace.Auth(), s.WorkspaceDirectory) diff --git a/workspace/workspace.go b/workspace/workspace.go index 5db3baf0ce2218f11106827bdca6dd26be93ca8d..be59477d799fd6a8b2dc40a08e92d7ad37be81b8 100644 --- a/workspace/workspace.go +++ b/workspace/workspace.go @@ -18,6 +18,7 @@ import ( "gitlab.com/lightmeter/controlcenter/messagerbl" "gitlab.com/lightmeter/controlcenter/meta" "gitlab.com/lightmeter/controlcenter/notification" + "gitlab.com/lightmeter/controlcenter/notification/slack" "gitlab.com/lightmeter/controlcenter/pkg/runner" "gitlab.com/lightmeter/controlcenter/po" "gitlab.com/lightmeter/controlcenter/settings/globalsettings" @@ -41,7 +42,8 @@ type Workspace struct { dashboard dashboard.Dashboard - NotificationCenter notification.Center + NotificationCenter *notification.Center + SlackNotifier *slack.Notifier settingsMetaHandler *meta.Handler settingsRunner *meta.Runner @@ -97,7 +99,11 @@ func NewWorkspace(workspaceDirectory string) (*Workspace, error) { translators := translator.New(po.DefaultCatalog) - notificationCenter := notification.New(m.Reader, translators) + notificationPolicies := notification.Policies{insights.DefaultNotificationPolicy{}} + + slackNotifier := slack.New(notificationPolicies, m.Reader) + + notificationCenter := notification.New(m.Reader, translators, []notification.Notifier{slackNotifier}) rblChecker := localrbl.NewChecker(m.Reader, localrbl.Options{ NumberOfWorkers: 10, @@ -135,6 +141,7 @@ func NewWorkspace(workspaceDirectory string) (*Workspace, error) { m, ), NotificationCenter: notificationCenter, + SlackNotifier: slackNotifier, } ws.CancelableRunner = runner.NewCancelableRunner(func(done runner.DoneChan, cancel runner.CancelChan) {