diff --git a/server/channels/app/notification_push.go b/server/channels/app/notification_push.go index fa05738a70..50e309d9a4 100644 --- a/server/channels/app/notification_push.go +++ b/server/channels/app/notification_push.go @@ -14,6 +14,8 @@ import ( "strings" "sync" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/i18n" "github.com/mattermost/mattermost/server/public/shared/mlog" @@ -138,6 +140,24 @@ func (a *App) sendPushNotificationToAllSessions(msg *model.PushNotification, use } func (a *App) sendPushNotification(notification *PostNotification, user *model.User, explicitMention, channelWideMention bool, replyToThreadType string) { + cancelled := false + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + cancelled = hooks.NotificationWillBePushed(&model.PluginPushNotification{ + Post: notification.Post.ForPlugin(), + Channel: notification.Channel, + UserID: user.Id, + }) + if cancelled { + mlog.Info("Notification cancelled by plugin") + return false + } + return true + }, plugin.NotificationWillBePushedID) + + if cancelled { + return + } + cfg := a.Config() channel := notification.Channel post := notification.Post diff --git a/server/channels/app/notification_push_test.go b/server/channels/app/notification_push_test.go index 0fd3e8848e..471b7a4a2d 100644 --- a/server/channels/app/notification_push_test.go +++ b/server/channels/app/notification_push_test.go @@ -1468,7 +1468,6 @@ func TestPushNotificationRace(t *testing.T) { require.NoError(t, err) s.products["channels"] = ch - app := New(ServerConnector(s.Channels())) require.NotPanics(t, func() { s.createPushNotificationsHub(th.Context) @@ -1476,9 +1475,9 @@ func TestPushNotificationRace(t *testing.T) { // Now we start sending messages after the PN hub is shut down. // We test all 3 notification types. - app.clearPushNotification("currentSessionId", "userId", "channelId", "") + th.App.clearPushNotification("currentSessionId", "userId", "channelId", "") - app.UpdateMobileAppBadge("userId") + th.App.UpdateMobileAppBadge("userId") notification := &PostNotification{ Post: &model.Post{}, @@ -1488,7 +1487,7 @@ func TestPushNotificationRace(t *testing.T) { }, Sender: &model.User{}, } - app.sendPushNotification(notification, &model.User{}, true, false, model.CommentsNotifyAny) + th.App.sendPushNotification(notification, &model.User{}, true, false, model.CommentsNotifyAny) }) } diff --git a/server/channels/app/plugin_hooks_test.go b/server/channels/app/plugin_hooks_test.go index 7657ead163..7e52cfc320 100644 --- a/server/channels/app/plugin_hooks_test.go +++ b/server/channels/app/plugin_hooks_test.go @@ -6,11 +6,14 @@ package app import ( "bytes" "context" + _ "embed" + "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" + "sync" "testing" "time" @@ -1331,3 +1334,118 @@ func TestHookOnCloudLimitsUpdated(t *testing.T) { require.True(t, hookCalled) } + +//go:embed test_templates/hook_notification_will_be_pushed.tmpl +var hookNotificationWillBePushedTmpl string + +func TestHookNotificationWillBePushed(t *testing.T) { + if testing.Short() { + t.Skip("skipping TestHookNotificationWillBePushed test in short mode") + } + + tests := []struct { + name string + testCode string + expectedNotifications int + }{ + { + name: "successfully pushed", + testCode: `return false`, + expectedNotifications: 6, + }, + { + name: "push notification rejected", + testCode: `return true`, + expectedNotifications: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + templatedPlugin := fmt.Sprintf(hookNotificationWillBePushedTmpl, tt.testCode) + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{templatedPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + // Create 3 users, each having 2 sessions. + type userSession struct { + user *model.User + session *model.Session + } + var userSessions []userSession + for i := 0; i < 3; i++ { + u := th.CreateUser() + sess, err := th.App.CreateSession(&model.Session{ + UserId: u.Id, + DeviceId: "deviceID" + u.Id, + ExpiresAt: model.GetMillis() + 100000, + }) + require.Nil(t, err) + // We don't need to track the 2nd session. + _, err = th.App.CreateSession(&model.Session{ + UserId: u.Id, + DeviceId: "deviceID" + u.Id, + ExpiresAt: model.GetMillis() + 100000, + }) + require.Nil(t, err) + _, err = th.App.AddTeamMember(th.Context, th.BasicTeam.Id, u.Id) + require.Nil(t, err) + th.AddUserToChannel(u, th.BasicChannel) + userSessions = append(userSessions, userSession{ + user: u, + session: sess, + }) + } + + handler := &testPushNotificationHandler{ + t: t, + behavior: "simple", + } + pushServer := httptest.NewServer( + http.HandlerFunc(handler.handleReq), + ) + defer pushServer.Close() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.PushNotificationContents = model.GenericNotification + *cfg.EmailSettings.PushNotificationServer = pushServer.URL + }) + + var wg sync.WaitGroup + for _, data := range userSessions { + wg.Add(1) + go func(user model.User) { + defer wg.Done() + notification := &PostNotification{ + Post: th.CreatePost(th.BasicChannel), + Channel: th.BasicChannel, + ProfileMap: map[string]*model.User{ + user.Id: &user, + }, + Sender: &user, + } + th.App.sendPushNotification(notification, &user, true, false, model.CommentsNotifyAny) + }(*data.user) + } + wg.Wait() + + // Hack to let the worker goroutines complete. + time.Sleep(1 * time.Second) + // Server side verification. + assert.Equal(t, tt.expectedNotifications, handler.numReqs()) + var numMessages int + for _, n := range handler.notifications() { + switch n.Type { + case model.PushTypeMessage: + numMessages++ + assert.Equal(t, th.BasicChannel.Id, n.ChannelId) + assert.Contains(t, n.Message, "mentioned you") + default: + assert.Fail(t, "should not receive any other push notification types") + } + } + assert.Equal(t, tt.expectedNotifications, numMessages) + }) + } +} diff --git a/server/channels/app/test_templates/hook_notification_will_be_pushed.tmpl b/server/channels/app/test_templates/hook_notification_will_be_pushed.tmpl new file mode 100644 index 0000000000..1f69483499 --- /dev/null +++ b/server/channels/app/test_templates/hook_notification_will_be_pushed.tmpl @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type MyPlugin struct { + plugin.MattermostPlugin +} + +func (p *MyPlugin) NotificationWillBePushed(notification *model.PluginPushNotification) (cancel bool) { + %s +} + +func main() { + plugin.ClientMain(&MyPlugin{}) +} diff --git a/server/public/model/plugin_push_notification.go b/server/public/model/plugin_push_notification.go new file mode 100644 index 0000000000..82c49a5b1c --- /dev/null +++ b/server/public/model/plugin_push_notification.go @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +// PluginPushNotification is sent to the plugin when a push notification is going to be sent (via the +// NotificationWillBePushed hook). +type PluginPushNotification struct { + Post *Post + Channel *Channel + UserID string +} diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index 451692adec..2b56537958 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -843,6 +843,40 @@ func (s *hooksRPCServer) ConfigurationWillBeSaved(args *Z_ConfigurationWillBeSav return nil } +func init() { + hookNameToId["NotificationWillBePushed"] = NotificationWillBePushedID +} + +type Z_NotificationWillBePushedArgs struct { + A *model.PluginPushNotification +} + +type Z_NotificationWillBePushedReturns struct { + A bool +} + +func (g *hooksRPCClient) NotificationWillBePushed(pushNotification *model.PluginPushNotification) (cancel bool) { + _args := &Z_NotificationWillBePushedArgs{pushNotification} + _returns := &Z_NotificationWillBePushedReturns{} + if g.implemented[NotificationWillBePushedID] { + if err := g.client.Call("Plugin.NotificationWillBePushed", _args, _returns); err != nil { + g.log.Error("RPC call NotificationWillBePushed to plugin failed.", mlog.Err(err)) + } + } + return _returns.A +} + +func (s *hooksRPCServer) NotificationWillBePushed(args *Z_NotificationWillBePushedArgs, returns *Z_NotificationWillBePushedReturns) error { + if hook, ok := s.impl.(interface { + NotificationWillBePushed(pushNotification *model.PluginPushNotification) (cancel bool) + }); ok { + returns.A = hook.NotificationWillBePushed(args.A) + } else { + return encodableError(fmt.Errorf("Hook NotificationWillBePushed called but not implemented.")) + } + return nil +} + type Z_RegisterCommandArgs struct { A *model.Command } diff --git a/server/public/plugin/hooks.go b/server/public/plugin/hooks.go index 54c656e2a4..80cfd1faef 100644 --- a/server/public/plugin/hooks.go +++ b/server/public/plugin/hooks.go @@ -50,6 +50,7 @@ const ( deprecatedGetCollectionMetadataByIdsID = 32 deprecatedGetTopicMetadataByIdsID = 33 ConfigurationWillBeSavedID = 34 + NotificationWillBePushedID = 35 TotalHooksID = iota ) @@ -284,4 +285,12 @@ type Hooks interface { // config object can be returned to be stored in place of the provided one. // Minimum server version: 8.0 ConfigurationWillBeSaved(newCfg *model.Config) (*model.Config, error) + + // NotificationWillBePushed is invoked before a push notification is sent to the push + // notification server. The intention is to allow plugins to cancel a push notification. + // + // To cancel a push notification, return true. + // + // Minimum server version: 9.0 + NotificationWillBePushed(pushNotification *model.PluginPushNotification) (cancel bool) } diff --git a/server/public/plugin/hooks_timer_layer_generated.go b/server/public/plugin/hooks_timer_layer_generated.go index de1dc04b33..564a725f2b 100644 --- a/server/public/plugin/hooks_timer_layer_generated.go +++ b/server/public/plugin/hooks_timer_layer_generated.go @@ -218,3 +218,10 @@ func (hooks *hooksTimerLayer) ConfigurationWillBeSaved(newCfg *model.Config) (*m hooks.recordTime(startTime, "ConfigurationWillBeSaved", _returnsB == nil) return _returnsA, _returnsB } + +func (hooks *hooksTimerLayer) NotificationWillBePushed(pushNotification *model.PluginPushNotification) (cancel bool) { + startTime := timePkg.Now() + _returnsA := hooks.hooksImpl.NotificationWillBePushed(pushNotification) + hooks.recordTime(startTime, "NotificationWillBePushed", true) + return _returnsA +} diff --git a/server/public/plugin/plugintest/hooks.go b/server/public/plugin/plugintest/hooks.go index b4c242ecfd..83710795d4 100644 --- a/server/public/plugin/plugintest/hooks.go +++ b/server/public/plugin/plugintest/hooks.go @@ -193,6 +193,20 @@ func (_m *Hooks) MessageWillBeUpdated(c *plugin.Context, newPost *model.Post, ol return r0, r1 } +// NotificationWillBePushed provides a mock function with given fields: pushNotification +func (_m *Hooks) NotificationWillBePushed(pushNotification *model.PluginPushNotification) bool { + ret := _m.Called(pushNotification) + + var r0 bool + if rf, ok := ret.Get(0).(func(*model.PluginPushNotification) bool); ok { + r0 = rf(pushNotification) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // OnActivate provides a mock function with given fields: func (_m *Hooks) OnActivate() error { ret := _m.Called() diff --git a/server/public/plugin/product_hooks_generated.go b/server/public/plugin/product_hooks_generated.go index be5269bd46..91b62747ec 100644 --- a/server/public/plugin/product_hooks_generated.go +++ b/server/public/plugin/product_hooks_generated.go @@ -118,6 +118,10 @@ type ConfigurationWillBeSavedIFace interface { ConfigurationWillBeSaved(newCfg *model.Config) (*model.Config, error) } +type NotificationWillBePushedIFace interface { + NotificationWillBePushed(pushNotification *model.PluginPushNotification) (cancel bool) +} + type HooksAdapter struct { implemented map[int]struct{} productHooks any @@ -365,6 +369,15 @@ func NewAdapter(productHooks any) (*HooksAdapter, error) { return nil, errors.New("hook has ConfigurationWillBeSaved method but does not implement plugin.ConfigurationWillBeSaved interface") } + // Assessing the type of the productHooks if it individually implements NotificationWillBePushed interface. + tt = reflect.TypeOf((*NotificationWillBePushedIFace)(nil)).Elem() + + if ft.Implements(tt) { + a.implemented[NotificationWillBePushedID] = struct{}{} + } else if _, ok := ft.MethodByName("NotificationWillBePushed"); ok { + return nil, errors.New("hook has NotificationWillBePushed method but does not implement plugin.NotificationWillBePushed interface") + } + return a, nil } @@ -601,3 +614,12 @@ func (a *HooksAdapter) ConfigurationWillBeSaved(newCfg *model.Config) (*model.Co return a.productHooks.(ConfigurationWillBeSavedIFace).ConfigurationWillBeSaved(newCfg) } + +func (a *HooksAdapter) NotificationWillBePushed(pushNotification *model.PluginPushNotification) (cancel bool) { + if _, ok := a.implemented[NotificationWillBePushedID]; !ok { + panic("product hooks must implement NotificationWillBePushed") + } + + return a.productHooks.(NotificationWillBePushedIFace).NotificationWillBePushed(pushNotification) + +}