Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MM-58292] Suppress push notifications to call thread if receiver is in call #807

Merged
merged 1 commit into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions server/db/calls_sessions_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,34 @@ func (s *Store) GetCallSessionsCount(callID string, opts GetCallSessionOpts) (in

return count, nil
}

func (s *Store) IsUserInCall(userID, callID string, opts GetCallSessionOpts) (bool, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of the performance impact of this approach? I guess we don't really have any other option, and it's not an event that will happen too often, but just curious.

Copy link
Collaborator Author

@streamer45 streamer45 Jul 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cpoile I'd expect this to be extremely efficient, given that callID is an index and the sessions table is very small. Plus the query doesn't need to return any structured data. The only further optimization would be an index on calls_sessions.userid, but given the premise (small table + filter on call), it's not worth it since a scan will be the best option anyway. To put it in comparison, I think it could be more costly the RPC hook call itself than the DB query.

s.metrics.IncStoreOp("IsUserInCall")
defer func(start time.Time) {
s.metrics.ObserveStoreMethodsTime("IsUserInCall", time.Since(start).Seconds())
}(time.Now())

qb := getQueryBuilder(s.driverName).Select("1").
From("calls_sessions").
Where(
sq.And{
sq.Eq{"CallID": callID},
sq.Eq{"UserID": userID},
})

q, args, err := qb.ToSql()
if err != nil {
return false, fmt.Errorf("failed to prepare query: %w", err)
}

var ok bool
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*s.settings.QueryTimeout)*time.Second)
defer cancel()
if err := s.dbXFromGetOpts(opts).GetContext(ctx, &ok, q, args...); err == sql.ErrNoRows {
return false, nil
} else if err != nil {
return false, fmt.Errorf("failed to get user in call: %w", err)
}

return ok, nil
}
58 changes: 58 additions & 0 deletions server/db/calls_sessions_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestCallsSessionsStore(t *testing.T) {
"TestGetCallSessions": testGetCallSessions,
"TestDeleteCallsSessions": testDeleteCallsSessions,
"TestGetCallSessionsCount": testGetCallSessionsCount,
"TestIsUserInCall": testIsUserInCall,
})
}

Expand Down Expand Up @@ -253,3 +254,60 @@ func testGetCallSessionsCount(t *testing.T, store *Store) {
require.Equal(t, 10, cnt)
})
}

func testIsUserInCall(t *testing.T, store *Store) {
t.Run("no sessions", func(t *testing.T) {
ok, err := store.IsUserInCall(model.NewId(), model.NewId(), GetCallSessionOpts{})
require.NoError(t, err)
require.False(t, ok)
})

t.Run("multiple sessions, user not in call", func(t *testing.T) {
sessions := map[string]*public.CallSession{}
callID := model.NewId()
for i := 0; i < 10; i++ {
session := &public.CallSession{
ID: model.NewId(),
CallID: callID,
UserID: model.NewId(),
JoinAt: time.Now().UnixMilli(),
}

err := store.CreateCallSession(session)
require.NoError(t, err)

sessions[session.ID] = session
}

ok, err := store.IsUserInCall(model.NewId(), callID, GetCallSessionOpts{})
require.NoError(t, err)
require.False(t, ok)
})

t.Run("multiple sessions, user in call", func(t *testing.T) {
sessions := map[string]*public.CallSession{}
callID := model.NewId()
userID := model.NewId()
for i := 0; i < 10; i++ {
if i > 0 {
userID = model.NewId()
}

session := &public.CallSession{
ID: model.NewId(),
CallID: callID,
UserID: userID,
JoinAt: time.Now().UnixMilli(),
}

err := store.CreateCallSession(session)
require.NoError(t, err)

sessions[session.ID] = session
}

ok, err := store.IsUserInCall(userID, callID, GetCallSessionOpts{})
require.NoError(t, err)
require.True(t, ok)
})
}
18 changes: 18 additions & 0 deletions server/push_notifications.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
package main

import (
"errors"
"fmt"

"github.com/mattermost/mattermost-plugin-calls/server/db"

"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)

func (p *Plugin) NotificationWillBePushed(notification *model.PushNotification, userID string) (*model.PushNotification, string) {
// If the user is in a call we suppress notifications for replies to the call thread.
call, err := p.store.GetActiveCallByChannelID(notification.ChannelId, db.GetCallOpts{})
if err == nil && call.ThreadID == notification.RootId {
isUserInCall, err := p.store.IsUserInCall(userID, call.ID, db.GetCallSessionOpts{})
if err != nil {
p.LogError("store.IsUserInCall failed", "err", err.Error())
} else if isUserInCall {
msg := "calls: suppressing notification on call thread for connected user"
p.LogDebug(msg, "userID", userID, "channelID", notification.ChannelId, "threadID", call.ThreadID, "callID", call.ID)
return nil, msg
}
} else if err != nil && !errors.Is(err, db.ErrNotFound) {
p.LogError("store.GetActiveCallByChannelID failed", "err", err.Error())
}

// We will use our own notifications if:
// 1. This is a call start post
// 2. We have enabled ringing
Expand Down
198 changes: 198 additions & 0 deletions server/push_notifications_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package main

import (
"testing"
"time"

"github.com/mattermost/mattermost-plugin-calls/server/public"

pluginMocks "github.com/mattermost/mattermost-plugin-calls/server/mocks/github.com/mattermost/mattermost/server/public/plugin"

"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"

"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestNotificationWillBePushed(t *testing.T) {
mockAPI := &pluginMocks.MockAPI{}

store, tearDown := NewTestStore(t)
t.Cleanup(tearDown)

p := Plugin{
MattermostPlugin: plugin.MattermostPlugin{
API: mockAPI,
},
store: store,
}

t.Run("not a call post", func(t *testing.T) {
res, msg := p.NotificationWillBePushed(&model.PushNotification{}, "userID")
require.Nil(t, res)
require.Empty(t, msg)
})

t.Run("user not in call", func(t *testing.T) {
channelID := model.NewId()
threadID := model.NewId()

err := p.store.CreateCall(&public.Call{
ID: model.NewId(),
CreateAt: time.Now().UnixMilli(),
ChannelID: channelID,
StartAt: time.Now().UnixMilli(),
PostID: model.NewId(),
ThreadID: threadID,
OwnerID: model.NewId(),
})
require.NoError(t, err)

res, msg := p.NotificationWillBePushed(&model.PushNotification{
ChannelId: channelID,
RootId: threadID,
}, "userID")
require.Nil(t, res)
require.Empty(t, msg)
})

t.Run("user in call", func(t *testing.T) {
defer mockAPI.AssertExpectations(t)

channelID := model.NewId()
threadID := model.NewId()
userID := model.NewId()
callID := model.NewId()

err := p.store.CreateCall(&public.Call{
ID: callID,
CreateAt: time.Now().UnixMilli(),
ChannelID: channelID,
StartAt: time.Now().UnixMilli(),
PostID: model.NewId(),
ThreadID: threadID,
OwnerID: model.NewId(),
})
require.NoError(t, err)

err = p.store.CreateCallSession(&public.CallSession{
ID: model.NewId(),
CallID: callID,
UserID: userID,
JoinAt: time.Now().UnixMilli(),
})
require.NoError(t, err)

mockAPI.On("LogDebug", "calls: suppressing notification on call thread for connected user",
"origin", mock.AnythingOfType("string"),
"userID", userID, "channelID", channelID, "threadID", threadID, "callID", callID).Once()

res, msg := p.NotificationWillBePushed(&model.PushNotification{
ChannelId: channelID,
RootId: threadID,
}, userID)
require.Nil(t, res)
require.Equal(t, "calls: suppressing notification on call thread for connected user", msg)
})

t.Run("DM/GM ringing", func(t *testing.T) {
defer mockAPI.AssertExpectations(t)
var cfg configuration
cfg.SetDefaults()

mockAPI.On("GetLicense").Return(&model.License{}, nil).Times(2)
*cfg.EnableRinging = true
err := p.setConfiguration(cfg.Clone())
require.NoError(t, err)
require.True(t, *p.getConfiguration().EnableRinging)

t.Run("not a call post", func(t *testing.T) {
res, msg := p.NotificationWillBePushed(&model.PushNotification{}, "userID")
require.Nil(t, res)
require.Empty(t, msg)
})

t.Run("call post but ringing disabled", func(t *testing.T) {
*cfg.EnableRinging = false
err := p.setConfiguration(cfg.Clone())
require.NoError(t, err)
require.False(t, *p.getConfiguration().EnableRinging)

res, msg := p.NotificationWillBePushed(&model.PushNotification{
PostType: callStartPostType,
}, "userID")
require.Nil(t, res)
require.Empty(t, msg)
})

t.Run("custom notification for DMs/GMs", func(t *testing.T) {
*cfg.EnableRinging = true
err := p.setConfiguration(cfg.Clone())
require.NoError(t, err)
require.True(t, *p.getConfiguration().EnableRinging)

res, msg := p.NotificationWillBePushed(&model.PushNotification{
PostType: callStartPostType,
ChannelType: model.ChannelTypeDirect,
}, "userID")
require.Nil(t, res)
require.Equal(t, "calls plugin will handle this notification", msg)

res, msg = p.NotificationWillBePushed(&model.PushNotification{
PostType: callStartPostType,
ChannelType: model.ChannelTypeGroup,
}, "userID")
require.Nil(t, res)
require.Equal(t, "calls plugin will handle this notification", msg)
})

t.Run("regular channel", func(t *testing.T) {
mockAPI.On("GetUser", "receiverID").Return(&model.User{
FirstName: "Firstname",
LastName: "Lastname",
}, nil).Twice()

var serverConfig model.Config
serverConfig.SetDefaults()
mockAPI.On("GetConfig").Return(&serverConfig).Once()

mockAPI.On("GetPreferencesForUser", "receiverID").Return([]model.Preference{}, nil).Once()

mockAPI.On("GetUser", "senderID").Return(&model.User{
FirstName: "Sender Firstname",
LastName: "Sender Lastname",
}, nil).Once()

res, msg := p.NotificationWillBePushed(&model.PushNotification{
PostType: callStartPostType,
ChannelType: model.ChannelTypeOpen,
SenderId: "senderID",
}, "receiverID")
require.Equal(t, &model.PushNotification{
PostType: callStartPostType,
ChannelType: model.ChannelTypeOpen,
SenderId: "senderID",
Message: "\u200bapp.push_notification.inviting_message",
}, res)
require.Empty(t, msg)

t.Run("id loaded", func(t *testing.T) {
res, msg := p.NotificationWillBePushed(&model.PushNotification{
PostType: callStartPostType,
ChannelType: model.ChannelTypeOpen,
SenderId: "senderID",
IsIdLoaded: true,
}, "receiverID")
require.Equal(t, &model.PushNotification{
PostType: callStartPostType,
ChannelType: model.ChannelTypeOpen,
SenderId: "senderID",
IsIdLoaded: true,
Message: "app.push_notification.generic_message",
}, res)
require.Empty(t, msg)
})
})
})
}
Loading