From 8418eefb7527d3f5966a9e4bff232b4988e96be4 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Fri, 18 Aug 2023 13:05:26 -0400 Subject: [PATCH] MM-52804 - Implement SendPushNotification plugin api method (#24273) * add SendPushNotification plugin api method * lint; add testing.Short bc of the sleep * add interface and generated layers * add fields to PluginPushNotification; generate mocks * SendPushNotification -> SendPluginPushNotification; improved comments * more comments; fix test * send api.ctx --- server/channels/app/notification_push.go | 9 +- server/channels/app/plugin_api.go | 28 ++++++ server/channels/app/plugin_api_test.go | 89 +++++++++++++++++++ .../public/model/plugin_push_notification.go | 17 +++- server/public/plugin/api.go | 9 ++ .../plugin/api_timer_layer_generated.go | 7 ++ server/public/plugin/client_rpc_generated.go | 29 ++++++ server/public/plugin/hooks.go | 3 + server/public/plugin/plugintest/api.go | 14 +++ 9 files changed, 198 insertions(+), 7 deletions(-) diff --git a/server/channels/app/notification_push.go b/server/channels/app/notification_push.go index 50e309d9a4..bfb7541bf2 100644 --- a/server/channels/app/notification_push.go +++ b/server/channels/app/notification_push.go @@ -143,9 +143,12 @@ func (a *App) sendPushNotification(notification *PostNotification, user *model.U 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, + Post: notification.Post.ForPlugin(), + Channel: notification.Channel, + UserID: user.Id, + ExplicitMention: explicitMention, + ChannelWideMention: channelWideMention, + ReplyToThreadType: replyToThreadType, }) if cancelled { mlog.Info("Notification cancelled by plugin") diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index 5fa7b56706..4cb9c43f2e 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -1266,3 +1266,31 @@ func (api *PluginAPI) GetUploadSession(uploadID string) (*model.UploadSession, e } return fi, nil } + +func (api *PluginAPI) SendPluginPushNotification(notification *model.PluginPushNotification) error { + var profiles map[string]*model.User + var err error + if notification.Channel.Type == model.ChannelTypeGroup { + if profiles, err = api.app.Srv().Store().User().GetAllProfilesInChannel(api.ctx.Context(), notification.Channel.Id, true); err != nil { + return err + } + } + + sender, appErr := api.app.GetUser(notification.Post.UserId) + if appErr != nil { + return appErr + } + user, appErr := api.app.GetUser(notification.UserID) + if appErr != nil { + return appErr + } + + postNotification := &PostNotification{ + Post: notification.Post, + Channel: notification.Channel, + ProfileMap: profiles, + Sender: sender, + } + api.app.sendPushNotification(postNotification, user, notification.ExplicitMention, notification.ChannelWideMention, notification.ReplyToThreadType) + return nil +} diff --git a/server/channels/app/plugin_api_test.go b/server/channels/app/plugin_api_test.go index 05c7d44c94..a3f91407a0 100644 --- a/server/channels/app/plugin_api_test.go +++ b/server/channels/app/plugin_api_test.go @@ -19,6 +19,7 @@ import ( "path" "path/filepath" "strings" + "sync" "testing" "time" @@ -2204,3 +2205,91 @@ func TestConfigurationWillBeSavedHook(t *testing.T) { }, newCfg.PluginSettings.Plugins["custom_plugin"]) }) } + +func TestSendPushNotification(t *testing.T) { + if testing.Short() { + t.Skip("skipping TestSendPushNotification test in short mode") + } + + th := Setup(t).InitBasic() + defer th.TearDown() + api := th.SetupPluginAPI() + + // 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.FullNotification + *cfg.EmailSettings.PushNotificationServer = pushServer.URL + }) + + var wg sync.WaitGroup + for _, data := range userSessions { + wg.Add(1) + go func(user model.User) { + defer wg.Done() + post := th.CreatePost(th.BasicChannel) + post.Message = "started a conversation" + notification := &model.PluginPushNotification{ + Post: post, + Channel: th.BasicChannel, + UserID: user.Id, + } + appErr := api.SendPluginPushNotification(notification) + require.NoError(t, appErr) + }(*data.user) + } + wg.Wait() + + // Hack to let the worker goroutines complete. + time.Sleep(1 * time.Second) + // Server side verification. + var numMessages int + for _, n := range handler.notifications() { + switch n.Type { + case model.PushTypeMessage: + numMessages++ + assert.Equal(t, th.BasicChannel.Id, n.ChannelId) + assert.Equal(t, fmt.Sprintf("@%s: started a conversation", th.BasicUser.GetDisplayName(model.ShowUsername)), n.Message) + default: + assert.Fail(t, "should not receive any other push notification types") + } + } + assert.Equal(t, 6, numMessages) +} diff --git a/server/public/model/plugin_push_notification.go b/server/public/model/plugin_push_notification.go index 82c49a5b1c..403af69122 100644 --- a/server/public/model/plugin_push_notification.go +++ b/server/public/model/plugin_push_notification.go @@ -4,9 +4,18 @@ package model // PluginPushNotification is sent to the plugin when a push notification is going to be sent (via the -// NotificationWillBePushed hook). +// NotificationWillBePushed hook), and is used as the source data for the plugin api SendPluginPushNotification method. +// +// Note: Please keep in mind that Mattermost push notifications always refer to a specific post. Therefore, when used +// as source data for `SendPluginPushNotification`, Post and Channel must be valid in order to create a correct +// model.PushNotification. +// Note: Post and Channel are pointers so that plugins using the NotificationWillBePushed hook will not need to query the +// database on every push notification. type PluginPushNotification struct { - Post *Post - Channel *Channel - UserID string + Post *Post // The post that will be used as the source of the push notification. + Channel *Channel // The channel that the post appeared in. + UserID string // The receiver of the push notification. + ExplicitMention bool // Used to construct the generic "@sender mentioned you" msg when `cfg.EmailSettings.PushNotificationContents` is not set to `full` + ChannelWideMention bool // Used to construct the generic "@sender notified the channel" msg when `cfg.EmailSettings.PushNotificationContents` is not set to `full` + ReplyToThreadType string // Used to construct the generic CRT msgs when `cfg.EmailSettings.PushNotificationContents` is not set to `full`; see `App.getPushNotificationMessage` for details. } diff --git a/server/public/plugin/api.go b/server/public/plugin/api.go index 6c2e2c1649..342399b2ec 100644 --- a/server/public/plugin/api.go +++ b/server/public/plugin/api.go @@ -1180,6 +1180,15 @@ type API interface { // @tag Upload // Minimum server version: 7.6 GetUploadSession(uploadID string) (*model.UploadSession, error) + + // SendPluginPushNotification will attempt to send a push notification to `notification.User`, using + // `notification.Post` as the source of the notification. The server will use the PluginPushNotification + // data to construct the final push notification according to the server's configuration and license. Refer + // to `App.BuildPushNotificationMessage` for the logic used to construct the push notification. + // Note: the NotificationWillBePushed hook will be run after SendPluginPushNotification is called. + // + // Minimum server version: 9.0 + SendPluginPushNotification(notification *model.PluginPushNotification) error } var handshake = plugin.HandshakeConfig{ diff --git a/server/public/plugin/api_timer_layer_generated.go b/server/public/plugin/api_timer_layer_generated.go index e3ed4723f3..4697bdf067 100644 --- a/server/public/plugin/api_timer_layer_generated.go +++ b/server/public/plugin/api_timer_layer_generated.go @@ -1266,3 +1266,10 @@ func (api *apiTimerLayer) GetUploadSession(uploadID string) (*model.UploadSessio api.recordTime(startTime, "GetUploadSession", _returnsB == nil) return _returnsA, _returnsB } + +func (api *apiTimerLayer) SendPluginPushNotification(notification *model.PluginPushNotification) error { + startTime := timePkg.Now() + _returnsA := api.apiImpl.SendPluginPushNotification(notification) + api.recordTime(startTime, "SendPluginPushNotification", _returnsA == nil) + return _returnsA +} diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index 2b56537958..492397a9f5 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -5868,3 +5868,32 @@ func (s *apiRPCServer) GetUploadSession(args *Z_GetUploadSessionArgs, returns *Z } return nil } + +type Z_SendPluginPushNotificationArgs struct { + A *model.PluginPushNotification +} + +type Z_SendPluginPushNotificationReturns struct { + A error +} + +func (g *apiRPCClient) SendPluginPushNotification(notification *model.PluginPushNotification) error { + _args := &Z_SendPluginPushNotificationArgs{notification} + _returns := &Z_SendPluginPushNotificationReturns{} + if err := g.client.Call("Plugin.SendPluginPushNotification", _args, _returns); err != nil { + log.Printf("RPC call to SendPluginPushNotification API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) SendPluginPushNotification(args *Z_SendPluginPushNotificationArgs, returns *Z_SendPluginPushNotificationReturns) error { + if hook, ok := s.impl.(interface { + SendPluginPushNotification(notification *model.PluginPushNotification) error + }); ok { + returns.A = hook.SendPluginPushNotification(args.A) + returns.A = encodableError(returns.A) + } else { + return encodableError(fmt.Errorf("API SendPluginPushNotification called but not implemented.")) + } + return nil +} diff --git a/server/public/plugin/hooks.go b/server/public/plugin/hooks.go index 80cfd1faef..7315c91129 100644 --- a/server/public/plugin/hooks.go +++ b/server/public/plugin/hooks.go @@ -291,6 +291,9 @@ type Hooks interface { // // To cancel a push notification, return true. // + // Note that this method will be called for push notification sent by plugins, including + // the plugin that created the push notification. + // // Minimum server version: 9.0 NotificationWillBePushed(pushNotification *model.PluginPushNotification) (cancel bool) } diff --git a/server/public/plugin/plugintest/api.go b/server/public/plugin/plugintest/api.go index 333b3b5a6b..d2cde99d8b 100644 --- a/server/public/plugin/plugintest/api.go +++ b/server/public/plugin/plugintest/api.go @@ -3639,6 +3639,20 @@ func (_m *API) SendMail(to string, subject string, htmlBody string) *model.AppEr return r0 } +// SendPluginPushNotification provides a mock function with given fields: notification +func (_m *API) SendPluginPushNotification(notification *model.PluginPushNotification) error { + ret := _m.Called(notification) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.PluginPushNotification) error); ok { + r0 = rf(notification) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SetProfileImage provides a mock function with given fields: userID, data func (_m *API) SetProfileImage(userID string, data []byte) *model.AppError { ret := _m.Called(userID, data)