diff --git a/app/notification.go b/app/notification.go index fbbc19ab1c..d652d2b0ec 100644 --- a/app/notification.go +++ b/app/notification.go @@ -36,6 +36,22 @@ func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *mod close(cmnchan) }() + var gchan chan store.StoreResult + if a.allowGroupMentions(post) { + gchan = make(chan store.StoreResult, 1) + go func() { + groups, err := a.Srv().Store.Group().GetGroups(0, 0, model.GroupSearchOpts{FilterAllowReference: true}) + groupsMap := make(map[string]*model.Group) + if err == nil { + for _, group := range groups { + groupsMap[group.Name] = group + } + } + gchan <- store.StoreResult{Data: groupsMap, Err: err} + close(gchan) + }() + } + var fchan chan store.StoreResult if len(post.FileIds) != 0 { fchan = make(chan store.StoreResult, 1) @@ -58,6 +74,15 @@ func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *mod } channelMemberNotifyPropsMap := result.Data.(map[string]model.StringMap) + groups := make(map[string]*model.Group) + if gchan != nil { + result = <-gchan + if result.Err != nil { + return nil, result.Err + } + groups = result.Data.(map[string]*model.Group) + } + mentions := &ExplicitMentions{} allActivityPushUserIds := []string{} @@ -76,7 +101,7 @@ func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *mod allowChannelMentions := a.allowChannelMentions(post, len(profileMap)) keywords := a.getMentionKeywordsInChannel(profileMap, allowChannelMentions, channelMemberNotifyPropsMap) - mentions = getExplicitMentions(post, keywords) + mentions = getExplicitMentions(post, keywords, groups) // Add an implicit mention when a user is added to a channel // even if the user has set 'username mentions' to false in account settings. @@ -87,6 +112,18 @@ func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *mod } } + // Iterate through all groups that were mentioned and insert group members into the list of mentions or potential mentions + for _, group := range mentions.GroupMentions { + anyUsersMentionedByGroup, err := a.insertGroupMentions(group, channel, profileMap, mentions) + if err != nil { + return nil, err + } + + if !anyUsersMentionedByGroup { + a.sendNoUsersNotifiedByGroupInChannel(sender, post, channel, group) + } + } + // get users that have comment thread mentions enabled if len(post.RootId) > 0 && parentPostList != nil { for _, threadPost := range parentPostList.Posts { @@ -373,6 +410,18 @@ func (a *App) userAllowsEmail(user *model.User, channelMemberNotificationProps m return userAllowsEmails && emailNotificationsAllowedForStatus && user.DeleteAt == 0 && !autoResponderRelated } +func (a *App) sendNoUsersNotifiedByGroupInChannel(sender *model.User, post *model.Post, channel *model.Channel, group *model.Group) { + T := utils.GetUserTranslations(sender.Locale) + ephemeralPost := &model.Post{ + UserId: sender.Id, + RootId: post.RootId, + ParentId: post.ParentId, + ChannelId: channel.Id, + Message: T("api.post.check_for_out_of_channel_group_users.message.none", model.StringInterface{"GroupDisplayName": group.DisplayName}), + } + a.SendEphemeralPost(post.UserId, ephemeralPost) +} + // sendOutOfChannelMentions sends an ephemeral post to the sender of a post if any of the given potential mentions // are outside of the post's channel. Returns whether or not an ephemeral post was sent. func (a *App) sendOutOfChannelMentions(sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) (bool, error) { @@ -520,6 +569,9 @@ type ExplicitMentions struct { // Mentions contains the ID of each user that was mentioned and how they were mentioned. Mentions map[string]MentionType + // Contains a map of groups that were mentioned + GroupMentions map[string]*model.Group + // OtherPotentialMentions contains a list of strings that looked like mentions, but didn't have // a corresponding keyword. OtherPotentialMentions []string @@ -556,6 +608,9 @@ const ( // The post contains an at-mention for the user KeywordMention + + // The post contains a group mention for the user + GroupMention ) func (m *ExplicitMentions) addMention(userId string, mentionType MentionType) { @@ -570,6 +625,32 @@ func (m *ExplicitMentions) addMention(userId string, mentionType MentionType) { m.Mentions[userId] = mentionType } +func (m *ExplicitMentions) addGroupMention(word string, groups map[string]*model.Group) bool { + if strings.HasPrefix(word, "@") { + word = word[1:] + } else { + // Only allow group mentions when mentioned directly with @group-name + return false + } + + group, groupFound := groups[word] + if !groupFound { + group = groups[strings.ToLower(word)] + } + + if group == nil { + return false + } + + if m.GroupMentions == nil { + m.GroupMentions = make(map[string]*model.Group) + } + + m.GroupMentions[group.Name] = group + + return true +} + func (m *ExplicitMentions) addMentions(userIds []string, mentionType MentionType) { for _, userId := range userIds { m.addMention(userId, mentionType) @@ -582,7 +663,7 @@ func (m *ExplicitMentions) removeMention(userId string) { // Given a message and a map mapping mention keywords to the users who use them, returns a map of mentioned // users and a slice of potential mention users not in the channel and whether or not @here was mentioned. -func getExplicitMentions(post *model.Post, keywords map[string][]string) *ExplicitMentions { +func getExplicitMentions(post *model.Post, keywords map[string][]string, groups map[string]*model.Group) *ExplicitMentions { ret := &ExplicitMentions{} buf := "" @@ -591,7 +672,7 @@ func getExplicitMentions(post *model.Post, keywords map[string][]string) *Explic markdown.Inspect(message, func(node interface{}) bool { text, ok := node.(*markdown.Text) if !ok { - ret.processText(buf, keywords) + ret.processText(buf, keywords, groups) buf = "" return true } @@ -599,7 +680,7 @@ func getExplicitMentions(post *model.Post, keywords map[string][]string) *Explic return false }) } - ret.processText(buf, keywords) + ret.processText(buf, keywords, groups) return ret } @@ -639,6 +720,19 @@ func (a *App) allowChannelMentions(post *model.Post, numProfiles int) bool { return true } +// allowGroupMentions returns whether or not the group mentions are allowed for the given post. +func (a *App) allowGroupMentions(post *model.Post) bool { + if !a.HasPermissionToChannel(post.UserId, post.ChannelId, model.PERMISSION_USE_GROUP_MENTIONS) { + return false + } + + if post.Type == model.POST_HEADER_CHANGE || post.Type == model.POST_PURPOSE_CHANGE { + return false + } + + return true +} + // Given a map of user IDs to profiles, returns a list of mention // keywords for all users in the channel. func (a *App) getMentionKeywordsInChannel(profiles map[string]*model.User, allowChannelMentions bool, channelMemberNotifyPropsMap map[string]model.StringMap) map[string][]string { @@ -657,6 +751,49 @@ func (a *App) getMentionKeywordsInChannel(profiles map[string]*model.User, allow return keywords } +// insertGroupMentions adds group members in the channel to Mentions, adds group members not in the channel to OtherPotentialMentions +// returns false if no group members present in the team that the channel belongs to +func (a *App) insertGroupMentions(group *model.Group, channel *model.Channel, profileMap map[string]*model.User, mentions *ExplicitMentions) (bool, *model.AppError) { + var err *model.AppError + var groupMembers []*model.User + outOfChannelGroupMembers := []*model.User{} + isGroupOrDirect := channel.IsGroupOrDirect() + + if isGroupOrDirect { + groupMembers, err = a.Srv().Store.Group().GetMemberUsers(group.Id) + } else { + groupMembers, err = a.Srv().Store.Group().GetMemberUsersInTeam(group.Id, channel.TeamId) + } + + if err != nil { + return false, err + } + + if mentions.Mentions == nil { + mentions.Mentions = make(map[string]MentionType) + } + + for _, member := range groupMembers { + if _, ok := profileMap[member.Id]; ok { + mentions.Mentions[member.Id] = GroupMention + } else { + outOfChannelGroupMembers = append(outOfChannelGroupMembers, member) + } + } + + potentialGroupMembersMentioned := []string{} + for _, user := range outOfChannelGroupMembers { + potentialGroupMembersMentioned = append(potentialGroupMembersMentioned, user.Username) + } + if mentions.OtherPotentialMentions == nil { + mentions.OtherPotentialMentions = potentialGroupMembersMentioned + } else { + mentions.OtherPotentialMentions = append(mentions.OtherPotentialMentions, potentialGroupMembersMentioned...) + } + + return isGroupOrDirect || len(groupMembers) > 0, nil +} + // addMentionKeywordsForUser adds the mention keywords for a given user to the given keyword map. Returns the provided keyword map. func addMentionKeywordsForUser(keywords map[string][]string, profile *model.User, channelNotifyProps map[string]string, status *model.Status, allowChannelMentions bool) map[string][]string { userMention := "@" + strings.ToLower(profile.Username) @@ -742,7 +879,7 @@ func (n *PostNotification) GetSenderName(userNameFormat string, overridesAllowed } // checkForMention checks if there is a mention to a specific user or to the keywords here / channel / all -func (m *ExplicitMentions) checkForMention(word string, keywords map[string][]string) bool { +func (m *ExplicitMentions) checkForMention(word string, keywords map[string][]string, groups map[string]*model.Group) bool { var mentionType MentionType switch strings.ToLower(word) { @@ -759,6 +896,8 @@ func (m *ExplicitMentions) checkForMention(word string, keywords map[string][]st mentionType = KeywordMention } + m.addGroupMention(word, groups) + if ids, match := keywords[strings.ToLower(word)]; match { m.addMentions(ids, mentionType) return true @@ -795,7 +934,7 @@ func isKeywordMultibyte(keywords map[string][]string, word string) ([]string, bo } // Processes text to filter mentioned users and other potential mentions -func (m *ExplicitMentions) processText(text string, keywords map[string][]string) { +func (m *ExplicitMentions) processText(text string, keywords map[string][]string, groups map[string]*model.Group) { systemMentions := map[string]bool{"@here": true, "@channel": true, "@all": true} for _, word := range strings.FieldsFunc(text, func(c rune) bool { @@ -809,16 +948,17 @@ func (m *ExplicitMentions) processText(text string, keywords map[string][]string word = strings.TrimLeft(word, ":.-_") - if m.checkForMention(word, keywords) { + if m.checkForMention(word, keywords, groups) { continue } foundWithoutSuffix := false wordWithoutSuffix := word + for len(wordWithoutSuffix) > 0 && strings.LastIndexAny(wordWithoutSuffix, ".-:_") == (len(wordWithoutSuffix)-1) { wordWithoutSuffix = wordWithoutSuffix[0 : len(wordWithoutSuffix)-1] - if m.checkForMention(wordWithoutSuffix, keywords) { + if m.checkForMention(wordWithoutSuffix, keywords, groups) { foundWithoutSuffix = true break } @@ -844,7 +984,7 @@ func (m *ExplicitMentions) processText(text string, keywords map[string][]string }) for _, splitWord := range splitWords { - if m.checkForMention(splitWord, keywords) { + if m.checkForMention(splitWord, keywords, groups) { continue } if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") { diff --git a/app/notification_test.go b/app/notification_test.go index 873bb6f463..027d4db1ec 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -304,6 +304,7 @@ func TestGetExplicitMentions(t *testing.T) { Message string Attachments []*model.SlackAttachment Keywords map[string][]string + Groups map[string]*model.Group Expected *ExplicitMentions }{ "Nobody": { @@ -799,6 +800,54 @@ func TestGetExplicitMentions(t *testing.T) { OtherPotentialMentions: []string{"other-one", "other", "other-two"}, }, }, + "No groups": { + Message: "@nothing", + Groups: map[string]*model.Group{}, + Expected: &ExplicitMentions{ + Mentions: nil, + OtherPotentialMentions: []string{"nothing"}, + }, + }, + "No matching groups": { + Message: "@nothing", + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}}, + Expected: &ExplicitMentions{ + Mentions: nil, + GroupMentions: nil, + OtherPotentialMentions: []string{"nothing"}, + }, + }, + "matching group with no @": { + Message: "engineering", + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}}, + Expected: &ExplicitMentions{ + Mentions: nil, + GroupMentions: nil, + OtherPotentialMentions: nil, + }, + }, + "matching group with preceeding @": { + Message: "@engineering", + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}}, + Expected: &ExplicitMentions{ + Mentions: nil, + GroupMentions: map[string]*model.Group{ + "engineering": {Name: "engineering"}, + }, + OtherPotentialMentions: []string{"engineering"}, + }, + }, + "matching upper case group with preceeding @": { + Message: "@Engineering", + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}}, + Expected: &ExplicitMentions{ + Mentions: nil, + GroupMentions: map[string]*model.Group{ + "engineering": {Name: "engineering"}, + }, + OtherPotentialMentions: []string{"Engineering"}, + }, + }, } { t.Run(name, func(t *testing.T) { post := &model.Post{ @@ -808,7 +857,7 @@ func TestGetExplicitMentions(t *testing.T) { }, } - m := getExplicitMentions(post, tc.Keywords) + m := getExplicitMentions(post, tc.Keywords, tc.Groups) assert.EqualValues(t, tc.Expected, m) }) @@ -861,7 +910,7 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { } for message, shouldMention := range cases { post := &model.Post{Message: message} - m := getExplicitMentions(post, nil) + m := getExplicitMentions(post, nil, nil) require.False(t, m.HereMentioned && !shouldMention, "shouldn't have mentioned @here with \"%v\"") require.False(t, !m.HereMentioned && shouldMention, "should've mentioned @here with \"%v\"") } @@ -869,7 +918,7 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { t.Run("Mention @here and someone", func(t *testing.T) { id := model.NewId() - m := getExplicitMentions(&model.Post{Message: "@here @user @potential"}, map[string][]string{"@user": {id}}) + m := getExplicitMentions(&model.Post{Message: "@here @user @potential"}, map[string][]string{"@user": {id}}, nil) require.True(t, m.HereMentioned, "should've mentioned @here with \"@here @user\"") require.Len(t, m.Mentions, 1) require.Equal(t, KeywordMention, m.Mentions[id], "should've mentioned @user with \"@here @user\"") @@ -879,11 +928,10 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { t.Run("Username ending with period", func(t *testing.T) { id := model.NewId() - m := getExplicitMentions(&model.Post{Message: "@potential. test"}, map[string][]string{"@user": {id}}) + m := getExplicitMentions(&model.Post{Message: "@potential. test"}, map[string][]string{"@user": {id}}, nil) require.Equal(t, len(m.OtherPotentialMentions), 1, "should've potential mentions for @potential") assert.Equal(t, "potential", m.OtherPotentialMentions[0]) }) - } func TestAllowChannelMentions(t *testing.T) { @@ -924,6 +972,41 @@ func TestAllowChannelMentions(t *testing.T) { }) } +func TestAllowGroupMentions(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + post := &model.Post{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser.Id} + + t.Run("should return true for a regular post with few channel members", func(t *testing.T) { + allowGroupMentions := th.App.allowGroupMentions(post) + assert.True(t, allowGroupMentions) + }) + + t.Run("should return false for a channel header post", func(t *testing.T) { + headerChangePost := &model.Post{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser.Id, Type: model.POST_HEADER_CHANGE} + allowGroupMentions := th.App.allowGroupMentions(headerChangePost) + assert.False(t, allowGroupMentions) + }) + + t.Run("should return false for a channel purpose post", func(t *testing.T) { + purposeChangePost := &model.Post{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser.Id, Type: model.POST_PURPOSE_CHANGE} + allowGroupMentions := th.App.allowGroupMentions(purposeChangePost) + assert.False(t, allowGroupMentions) + }) + + t.Run("should return false for a post where the post user does not have USE_GROUP_MENTIONS permission", func(t *testing.T) { + defer func() { + th.AddPermissionToRole(model.PERMISSION_USE_GROUP_MENTIONS.Id, model.CHANNEL_USER_ROLE_ID) + th.AddPermissionToRole(model.PERMISSION_USE_GROUP_MENTIONS.Id, model.CHANNEL_ADMIN_ROLE_ID) + }() + th.RemovePermissionFromRole(model.PERMISSION_USE_GROUP_MENTIONS.Id, model.CHANNEL_USER_ROLE_ID) + th.RemovePermissionFromRole(model.PERMISSION_USE_GROUP_MENTIONS.Id, model.CHANNEL_ADMIN_ROLE_ID) + allowGroupMentions := th.App.allowGroupMentions(post) + assert.False(t, allowGroupMentions) + }) +} + func TestGetMentionKeywords(t *testing.T) { th := Setup(t) defer th.TearDown() @@ -1669,6 +1752,7 @@ func TestIsKeywordMultibyte(t *testing.T) { Message string Attachments []*model.SlackAttachment Keywords map[string][]string + Groups map[string]*model.Group Expected *ExplicitMentions }{ "MultibyteCharacter": { @@ -1760,10 +1844,7 @@ func TestIsKeywordMultibyte(t *testing.T) { }, } - m := getExplicitMentions(post, tc.Keywords) - // if tc.Expected.MentionedUserIds == nil { - // tc.Expected.MentionedUserIds = make(map[string]bool) - // } + m := getExplicitMentions(post, tc.Keywords, tc.Groups) assert.EqualValues(t, tc.Expected, m) }) } @@ -1913,23 +1994,71 @@ func TestCheckForMentionUsers(t *testing.T) { t.Run(name, func(t *testing.T) { e := &ExplicitMentions{} - e.checkForMention(tc.Word, tc.Keywords) + e.checkForMention(tc.Word, tc.Keywords, nil) assert.EqualValues(t, tc.Expected, e) }) } } + +func TestAddGroupMention(t *testing.T) { + for name, tc := range map[string]struct { + Word string + Groups map[string]*model.Group + Expected bool + }{ + "No groups": { + Word: "nothing", + Groups: map[string]*model.Group{}, + Expected: false, + }, + "No matching groups": { + Word: "nothing", + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, + Expected: false, + }, + "matching group with no @": { + Word: "engineering", + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, + Expected: false, + }, + "matching group with preceeding @": { + Word: "@engineering", + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, + Expected: true, + }, + "matching upper case group with preceeding @": { + Word: "@Engineering", + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, + Expected: true, + }, + } { + t.Run(name, func(t *testing.T) { + e := &ExplicitMentions{} + groupFound := e.addGroupMention(tc.Word, tc.Groups) + + if groupFound { + require.Equal(t, len(e.GroupMentions), 1) + } + + require.Equal(t, tc.Expected, groupFound) + }) + } +} + func TestProcessText(t *testing.T) { id1 := model.NewId() for name, tc := range map[string]struct { Text string Keywords map[string][]string + Groups map[string]*model.Group Expected *ExplicitMentions }{ "Mention user in text": { Text: "hello user @user1", Keywords: map[string][]string{"@user1": {id1}}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, Expected: &ExplicitMentions{ Mentions: map[string]MentionType{ id1: KeywordMention, @@ -1939,6 +2068,7 @@ func TestProcessText(t *testing.T) { "Mention user after ending a sentence with full stop": { Text: "hello user.@user1", Keywords: map[string][]string{"@user1": {id1}}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, Expected: &ExplicitMentions{ Mentions: map[string]MentionType{ id1: KeywordMention, @@ -1957,6 +2087,7 @@ func TestProcessText(t *testing.T) { "Mention user after colon": { Text: "hello user:@user1", Keywords: map[string][]string{"@user1": {id1}}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, Expected: &ExplicitMentions{ Mentions: map[string]MentionType{ id1: KeywordMention, @@ -1966,6 +2097,7 @@ func TestProcessText(t *testing.T) { "Mention here after colon": { Text: "hello all:@here", Keywords: map[string][]string{}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, Expected: &ExplicitMentions{ HereMentioned: true, }, @@ -1973,6 +2105,7 @@ func TestProcessText(t *testing.T) { "Mention all after hyphen": { Text: "hello all-@all", Keywords: map[string][]string{}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, Expected: &ExplicitMentions{ AllMentioned: true, }, @@ -1980,6 +2113,7 @@ func TestProcessText(t *testing.T) { "Mention channel after full stop": { Text: "hello channel.@channel", Keywords: map[string][]string{}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, Expected: &ExplicitMentions{ ChannelMentioned: true, }, @@ -1987,6 +2121,7 @@ func TestProcessText(t *testing.T) { "Mention other pontential users or system calls": { Text: "hello @potentialuser and @otherpotentialuser", Keywords: map[string][]string{}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, Expected: &ExplicitMentions{ OtherPotentialMentions: []string{"potentialuser", "otherpotentialuser"}, }, @@ -1994,6 +2129,7 @@ func TestProcessText(t *testing.T) { "Mention a real user and another potential user": { Text: "@user1, you can use @systembot to get help", Keywords: map[string][]string{"@user1": {id1}}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, Expected: &ExplicitMentions{ Mentions: map[string]MentionType{ id1: KeywordMention, @@ -2001,10 +2137,31 @@ func TestProcessText(t *testing.T) { OtherPotentialMentions: []string{"systembot"}, }, }, + "Mention a group": { + Text: "@engineering", + Keywords: map[string][]string{"@user1": {id1}}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, + Expected: &ExplicitMentions{ + GroupMentions: map[string]*model.Group{"engineering": {Name: "engineering"}}, + OtherPotentialMentions: []string{"engineering"}, + }, + }, + "Mention a real user and another potential user and a group": { + Text: "@engineering @user1, you can use @systembot to get help from", + Keywords: map[string][]string{"@user1": {id1}}, + Groups: map[string]*model.Group{"engineering": {Name: "engineering"}, "developers": {Name: "developers"}}, + Expected: &ExplicitMentions{ + Mentions: map[string]MentionType{ + id1: KeywordMention, + }, + GroupMentions: map[string]*model.Group{"engineering": {Name: "engineering"}}, + OtherPotentialMentions: []string{"engineering", "systembot"}, + }, + }, } { t.Run(name, func(t *testing.T) { e := &ExplicitMentions{} - e.processText(tc.Text, tc.Keywords) + e.processText(tc.Text, tc.Keywords, tc.Groups) assert.EqualValues(t, tc.Expected, e) }) @@ -2130,3 +2287,105 @@ func TestUserAllowsEmail(t *testing.T) { }) } + +func TestInsertGroupMentions(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + team := th.BasicTeam + channel := th.BasicChannel + group := th.CreateGroup() + group.DisplayName = "engineering" + group.Name = "engineering" + group, err := th.App.UpdateGroup(group) + require.Nil(t, err) + + groupChannelMember := th.CreateUser() + th.LinkUserToTeam(groupChannelMember, team) + th.App.AddUserToChannel(groupChannelMember, channel) + _, err = th.App.UpsertGroupMember(group.Id, groupChannelMember.Id) + require.Nil(t, err) + + nonGroupChannelMember := th.CreateUser() + th.LinkUserToTeam(nonGroupChannelMember, team) + th.App.AddUserToChannel(nonGroupChannelMember, channel) + + nonChannelGroupMember := th.CreateUser() + th.LinkUserToTeam(nonChannelGroupMember, team) + _, err = th.App.UpsertGroupMember(group.Id, nonChannelGroupMember.Id) + require.Nil(t, err) + + groupWithNoMembers := th.CreateGroup() + groupWithNoMembers.DisplayName = "marketing" + groupWithNoMembers.Name = "marketing" + groupWithNoMembers, err = th.App.UpdateGroup(groupWithNoMembers) + require.Nil(t, err) + + profileMap := map[string]*model.User{groupChannelMember.Id: groupChannelMember, nonGroupChannelMember.Id: nonGroupChannelMember} + + t.Run("should add expected mentions for users part of the mentioned group", func(t *testing.T) { + mentions := &ExplicitMentions{} + usersMentioned, err := th.App.insertGroupMentions(group, channel, profileMap, mentions) + require.Nil(t, err) + require.Equal(t, usersMentioned, true) + + // Ensure group member that is also a channel member is added to the mentions list. + require.Equal(t, len(mentions.Mentions), 1) + _, found := mentions.Mentions[groupChannelMember.Id] + require.Equal(t, found, true) + + // Ensure group member that is not a channel member is added to the other potential mentions list. + require.Equal(t, len(mentions.OtherPotentialMentions), 1) + require.Equal(t, mentions.OtherPotentialMentions[0], nonChannelGroupMember.Username) + }) + + t.Run("should add no expected or potential mentions if the group has no users ", func(t *testing.T) { + mentions := &ExplicitMentions{} + usersMentioned, err := th.App.insertGroupMentions(groupWithNoMembers, channel, profileMap, mentions) + require.Nil(t, err) + require.Equal(t, usersMentioned, false) + + // Ensure no mentions are added for a group with no users + require.Equal(t, len(mentions.Mentions), 0) + require.Equal(t, len(mentions.OtherPotentialMentions), 0) + }) + + t.Run("should keep existing mentions", func(t *testing.T) { + mentions := &ExplicitMentions{} + th.App.insertGroupMentions(group, channel, profileMap, mentions) + th.App.insertGroupMentions(groupWithNoMembers, channel, profileMap, mentions) + + // Ensure mentions from group are kept after running with groupWithNoMembers + require.Equal(t, len(mentions.Mentions), 1) + require.Equal(t, len(mentions.OtherPotentialMentions), 1) + }) + + t.Run("should return true if no members mentioned while in group or direct message channel", func(t *testing.T) { + mentions := &ExplicitMentions{} + emptyProfileMap := make(map[string]*model.User) + + groupChannel := &model.Channel{Type: model.CHANNEL_GROUP} + usersMentioned, _ := th.App.insertGroupMentions(group, groupChannel, emptyProfileMap, mentions) + // Ensure group channel with no group members mentioned always returns true + require.Equal(t, usersMentioned, true) + require.Equal(t, len(mentions.Mentions), 0) + + directChannel := &model.Channel{Type: model.CHANNEL_DIRECT} + usersMentioned, _ = th.App.insertGroupMentions(group, directChannel, emptyProfileMap, mentions) + // Ensure direct channel with no group members mentioned always returns true + require.Equal(t, usersMentioned, true) + require.Equal(t, len(mentions.Mentions), 0) + }) + + t.Run("should add mentions for members while in group channel", func(t *testing.T) { + groupChannel, err := th.App.CreateGroupChannel([]string{groupChannelMember.Id, nonGroupChannelMember.Id, th.BasicUser.Id}, groupChannelMember.Id) + require.Nil(t, err) + + mentions := &ExplicitMentions{} + th.App.insertGroupMentions(group, groupChannel, profileMap, mentions) + + require.Equal(t, len(mentions.Mentions), 1) + _, found := mentions.Mentions[groupChannelMember.Id] + require.Equal(t, found, true) + }) +} diff --git a/app/post.go b/app/post.go index 86ec8a1855..a8a346c9cc 100644 --- a/app/post.go +++ b/app/post.go @@ -1230,7 +1230,7 @@ func isPostMention(user *model.User, post *model.Post, keywords map[string][]str } // Check for keyword mentions - mentions := getExplicitMentions(post, keywords) + mentions := getExplicitMentions(post, keywords, make(map[string]*model.Group)) if _, ok := mentions.Mentions[user.Id]; ok { return true } diff --git a/i18n/en.json b/i18n/en.json index 59ab9f59f9..3c730c2a57 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1600,6 +1600,10 @@ "id": "api.plugin.verify_plugin.app_error", "translation": "Unable to verify plugin signature." }, + { + "id": "api.post.check_for_out_of_channel_group_users.message.none", + "translation": "@{{.GroupDisplayName}} has no members on this team" + }, { "id": "api.post.check_for_out_of_channel_groups_mentions.message.multiple", "translation": "@{{.Usernames}} and @{{.LastUsername}} did not get notified by this mention because they are not in the channel. They cannot be added to the channel because they are not a member of the linked groups. To add them to this channel, they must be added to the linked groups." diff --git a/store/opentracing_layer.go b/store/opentracing_layer.go index 79adf9a941..fb29da319a 100644 --- a/store/opentracing_layer.go +++ b/store/opentracing_layer.go @@ -3337,6 +3337,42 @@ func (s *OpenTracingLayerGroupStore) GetMemberUsers(groupID string) ([]*model.Us return resultVar0, resultVar1 } +func (s *OpenTracingLayerGroupStore) GetMemberUsersInTeam(groupID string, teamID string) ([]*model.User, *model.AppError) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.GetMemberUsersInTeam") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.GroupStore.GetMemberUsersInTeam(groupID, teamID) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (s *OpenTracingLayerGroupStore) GetMemberUsersNotInChannel(groupID string, channelID string) ([]*model.User, *model.AppError) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.GetMemberUsersNotInChannel") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.GroupStore.GetMemberUsersNotInChannel(groupID, channelID) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (s *OpenTracingLayerGroupStore) GetMemberUsersPage(groupID string, page int, perPage int) ([]*model.User, *model.AppError) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.GetMemberUsersPage") diff --git a/store/sqlstore/group_store.go b/store/sqlstore/group_store.go index 66105efa5c..8b2f60e4dc 100644 --- a/store/sqlstore/group_store.go +++ b/store/sqlstore/group_store.go @@ -260,7 +260,7 @@ func (s *SqlGroupStore) GetMemberUsers(groupID string) ([]*model.User, *model.Ap AND GroupId = :GroupId` if _, err := s.GetReplica().Select(&groupMembers, query, map[string]interface{}{"GroupId": groupID}); err != nil { - return nil, model.NewAppError("SqlGroupStore.GroupGetAllBySource", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + return nil, model.NewAppError("SqlGroupStore.GetMemberUsers", "store.select_error", nil, err.Error(), http.StatusInternalServerError) } return groupMembers, nil @@ -312,6 +312,70 @@ func (s *SqlGroupStore) GetMemberCount(groupID string) (int64, *model.AppError) return count, nil } +func (s *SqlGroupStore) GetMemberUsersInTeam(groupID string, teamID string) ([]*model.User, *model.AppError) { + var groupMembers []*model.User + + query := ` + SELECT + Users.* + FROM + GroupMembers + JOIN Users ON Users.Id = GroupMembers.UserId + WHERE + GroupId = :GroupId + AND GroupMembers.UserId IN ( + SELECT TeamMembers.UserId + FROM TeamMembers + JOIN Teams ON Teams.Id = :TeamId + WHERE TeamMembers.TeamId = Teams.Id + AND TeamMembers.DeleteAt = 0 + ) + AND GroupMembers.DeleteAt = 0 + AND Users.DeleteAt = 0 + ` + + if _, err := s.GetReplica().Select(&groupMembers, query, map[string]interface{}{"GroupId": groupID, "TeamId": teamID}); err != nil { + return nil, model.NewAppError("SqlGroupStore.GetMemberUsersInTeam", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + } + + return groupMembers, nil +} + +func (s *SqlGroupStore) GetMemberUsersNotInChannel(groupID string, channelID string) ([]*model.User, *model.AppError) { + var groupMembers []*model.User + + query := ` + SELECT + Users.* + FROM + GroupMembers + JOIN Users ON Users.Id = GroupMembers.UserId + WHERE + GroupId = :GroupId + AND GroupMembers.UserId NOT IN ( + SELECT ChannelMembers.UserId + FROM ChannelMembers + WHERE ChannelMembers.ChannelId = :ChannelId + ) + AND GroupMembers.UserId IN ( + SELECT TeamMembers.UserId + FROM TeamMembers + JOIN Channels ON Channels.Id = :ChannelId + JOIN Teams ON Teams.Id = Channels.TeamId + WHERE TeamMembers.TeamId = Teams.Id + AND TeamMembers.DeleteAt = 0 + ) + AND GroupMembers.DeleteAt = 0 + AND Users.DeleteAt = 0 + ` + + if _, err := s.GetReplica().Select(&groupMembers, query, map[string]interface{}{"GroupId": groupID, "ChannelId": channelID}); err != nil { + return nil, model.NewAppError("SqlGroupStore.GetMemberUsersNotInChannel", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + } + + return groupMembers, nil +} + func (s *SqlGroupStore) UpsertMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { member := &model.GroupMember{ GroupId: groupID, @@ -1081,10 +1145,14 @@ func (s *SqlGroupStore) GetGroups(page, perPage int, opts model.GroupSearchOpts) groupsQuery = groupsQuery. From("UserGroups g"). Where("g.DeleteAt = 0"). - Limit(uint64(perPage)). - Offset(uint64(page * perPage)). OrderBy("g.DisplayName") + if perPage != 0 { + groupsQuery = groupsQuery. + Limit(uint64(perPage)). + Offset(uint64(page * perPage)) + } + if opts.FilterAllowReference { groupsQuery = groupsQuery.Where("g.AllowReference = true") } diff --git a/store/store.go b/store/store.go index 868e3ef38b..5ac95bd686 100644 --- a/store/store.go +++ b/store/store.go @@ -629,6 +629,10 @@ type GroupStore interface { GetMemberUsers(groupID string) ([]*model.User, *model.AppError) GetMemberUsersPage(groupID string, page int, perPage int) ([]*model.User, *model.AppError) GetMemberCount(groupID string) (int64, *model.AppError) + + GetMemberUsersInTeam(groupID string, teamID string) ([]*model.User, *model.AppError) + GetMemberUsersNotInChannel(groupID string, channelID string) ([]*model.User, *model.AppError) + UpsertMember(groupID string, userID string) (*model.GroupMember, *model.AppError) DeleteMember(groupID string, userID string) (*model.GroupMember, *model.AppError) PermanentDeleteMembersByUser(userId string) *model.AppError diff --git a/store/storetest/group_store.go b/store/storetest/group_store.go index 14e34af541..903abc24f5 100644 --- a/store/storetest/group_store.go +++ b/store/storetest/group_store.go @@ -32,6 +32,10 @@ func TestGroupStore(t *testing.T, ss store.Store) { t.Run("GetMemberUsers", func(t *testing.T) { testGroupGetMemberUsers(t, ss) }) t.Run("GetMemberUsersPage", func(t *testing.T) { testGroupGetMemberUsersPage(t, ss) }) + + t.Run("GetMemberUsersInTeam", func(t *testing.T) { testGroupGetMemberUsersInTeam(t, ss) }) + t.Run("GetMemberUsersNotInChannel", func(t *testing.T) { testGroupGetMemberUsersNotInChannel(t, ss) }) + t.Run("UpsertMember", func(t *testing.T) { testUpsertMember(t, ss) }) t.Run("DeleteMember", func(t *testing.T) { testGroupDeleteMember(t, ss) }) t.Run("PermanentDeleteMembersByUser", func(t *testing.T) { testGroupPermanentDeleteMembersByUser(t, ss) }) @@ -682,6 +686,231 @@ func testGroupGetMemberUsersPage(t *testing.T, ss store.Store) { require.Equal(t, 2, len(groupMembers)) } +func testGroupGetMemberUsersInTeam(t *testing.T, ss store.Store) { + // Save a team + team := &model.Team{ + DisplayName: "Name", + Description: "Some description", + CompanyName: "Some company name", + Name: "z-z-" + model.NewId() + "a", + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + team, err := ss.Team().Save(team) + require.Nil(t, err) + + // Save a group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + group, err := ss.Group().Create(g1) + require.Nil(t, err) + + u1 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + user1, err := ss.User().Save(u1) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(group.Id, user1.Id) + require.Nil(t, err) + + u2 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + user2, err := ss.User().Save(u2) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(group.Id, user2.Id) + require.Nil(t, err) + + u3 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + user3, err := ss.User().Save(u3) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(group.Id, user3.Id) + require.Nil(t, err) + + // returns no members when team does not exist + groupMembers, err := ss.Group().GetMemberUsersInTeam(group.Id, "non-existant-channel-id") + require.Nil(t, err) + require.Equal(t, 0, len(groupMembers)) + + // returns no members when group has no members in the team + groupMembers, err = ss.Group().GetMemberUsersInTeam(group.Id, team.Id) + require.Nil(t, err) + require.Equal(t, 0, len(groupMembers)) + + m1 := &model.TeamMember{TeamId: team.Id, UserId: user1.Id} + _, err = ss.Team().SaveMember(m1, -1) + require.Nil(t, err) + + // returns single member in team + groupMembers, err = ss.Group().GetMemberUsersInTeam(group.Id, team.Id) + require.Nil(t, err) + require.Equal(t, 1, len(groupMembers)) + + m2 := &model.TeamMember{TeamId: team.Id, UserId: user2.Id} + m3 := &model.TeamMember{TeamId: team.Id, UserId: user3.Id} + _, err = ss.Team().SaveMember(m2, -1) + require.Nil(t, err) + _, err = ss.Team().SaveMember(m3, -1) + require.Nil(t, err) + + // returns all members when all members are in team + groupMembers, err = ss.Group().GetMemberUsersInTeam(group.Id, team.Id) + require.Nil(t, err) + require.Equal(t, 3, len(groupMembers)) +} + +func testGroupGetMemberUsersNotInChannel(t *testing.T, ss store.Store) { + // Save a team + team := &model.Team{ + DisplayName: "Name", + Description: "Some description", + CompanyName: "Some company name", + Name: "z-z-" + model.NewId() + "a", + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + team, err := ss.Team().Save(team) + require.Nil(t, err) + + // Save a group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + group, err := ss.Group().Create(g1) + require.Nil(t, err) + + u1 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + user1, err := ss.User().Save(u1) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(group.Id, user1.Id) + require.Nil(t, err) + + u2 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + user2, err := ss.User().Save(u2) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(group.Id, user2.Id) + require.Nil(t, err) + + u3 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + user3, err := ss.User().Save(u3) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(group.Id, user3.Id) + require.Nil(t, err) + + // Create Channel + channel := &model.Channel{ + TeamId: team.Id, + DisplayName: "Channel", + Name: model.NewId(), + Type: model.CHANNEL_OPEN, // Query does not look at type so this shouldn't matter. + } + channel, err = ss.Channel().Save(channel, 9999) + require.Nil(t, err) + + // returns no members when channel does not exist + groupMembers, err := ss.Group().GetMemberUsersNotInChannel(group.Id, "non-existant-channel-id") + require.Nil(t, err) + require.Equal(t, 0, len(groupMembers)) + + // returns no members when group has no members in the team that the channel belongs to + groupMembers, err = ss.Group().GetMemberUsersNotInChannel(group.Id, channel.Id) + require.Nil(t, err) + require.Equal(t, 0, len(groupMembers)) + + m1 := &model.TeamMember{TeamId: team.Id, UserId: user1.Id} + _, err = ss.Team().SaveMember(m1, -1) + require.Nil(t, err) + + // returns single member in team and not in channel + groupMembers, err = ss.Group().GetMemberUsersNotInChannel(group.Id, channel.Id) + require.Nil(t, err) + require.Equal(t, 1, len(groupMembers)) + + m2 := &model.TeamMember{TeamId: team.Id, UserId: user2.Id} + m3 := &model.TeamMember{TeamId: team.Id, UserId: user3.Id} + _, err = ss.Team().SaveMember(m2, -1) + require.Nil(t, err) + _, err = ss.Team().SaveMember(m3, -1) + require.Nil(t, err) + + // returns all members when all members are in team and not in channel + groupMembers, err = ss.Group().GetMemberUsersNotInChannel(group.Id, channel.Id) + require.Nil(t, err) + require.Equal(t, 3, len(groupMembers)) + + cm1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user1.Id, + SchemeGuest: false, + SchemeUser: true, + SchemeAdmin: false, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + _, err = ss.Channel().SaveMember(cm1) + require.Nil(t, err) + + // returns both members not yet added to channel + groupMembers, err = ss.Group().GetMemberUsersNotInChannel(group.Id, channel.Id) + require.Nil(t, err) + require.Equal(t, 2, len(groupMembers)) + + cm2 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user2.Id, + SchemeGuest: false, + SchemeUser: true, + SchemeAdmin: false, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + cm3 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user3.Id, + SchemeGuest: false, + SchemeUser: true, + SchemeAdmin: false, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err = ss.Channel().SaveMember(cm2) + require.Nil(t, err) + _, err = ss.Channel().SaveMember(cm3) + require.Nil(t, err) + + // returns none when all members have been added to team and channel + groupMembers, err = ss.Group().GetMemberUsersNotInChannel(group.Id, channel.Id) + require.Nil(t, err) + require.Equal(t, 0, len(groupMembers)) +} + func testUpsertMember(t *testing.T, ss store.Store) { // Create group g1 := &model.Group{ diff --git a/store/storetest/mocks/GroupStore.go b/store/storetest/mocks/GroupStore.go index fb9c818bc1..746c298caf 100644 --- a/store/storetest/mocks/GroupStore.go +++ b/store/storetest/mocks/GroupStore.go @@ -702,6 +702,56 @@ func (_m *GroupStore) GetMemberUsers(groupID string) ([]*model.User, *model.AppE return r0, r1 } +// GetMemberUsersInTeam provides a mock function with given fields: groupID, teamID +func (_m *GroupStore) GetMemberUsersInTeam(groupID string, teamID string) ([]*model.User, *model.AppError) { + ret := _m.Called(groupID, teamID) + + var r0 []*model.User + if rf, ok := ret.Get(0).(func(string, string) []*model.User); ok { + r0 = rf(groupID, teamID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func(string, string) *model.AppError); ok { + r1 = rf(groupID, teamID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetMemberUsersNotInChannel provides a mock function with given fields: groupID, channelID +func (_m *GroupStore) GetMemberUsersNotInChannel(groupID string, channelID string) ([]*model.User, *model.AppError) { + ret := _m.Called(groupID, channelID) + + var r0 []*model.User + if rf, ok := ret.Get(0).(func(string, string) []*model.User); ok { + r0 = rf(groupID, channelID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func(string, string) *model.AppError); ok { + r1 = rf(groupID, channelID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // GetMemberUsersPage provides a mock function with given fields: groupID, page, perPage func (_m *GroupStore) GetMemberUsersPage(groupID string, page int, perPage int) ([]*model.User, *model.AppError) { ret := _m.Called(groupID, page, perPage) diff --git a/store/timer_layer.go b/store/timer_layer.go index 9eafa9a259..6677508d43 100644 --- a/store/timer_layer.go +++ b/store/timer_layer.go @@ -3054,6 +3054,38 @@ func (s *TimerLayerGroupStore) GetMemberUsers(groupID string) ([]*model.User, *m return resultVar0, resultVar1 } +func (s *TimerLayerGroupStore) GetMemberUsersInTeam(groupID string, teamID string) ([]*model.User, *model.AppError) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.GroupStore.GetMemberUsersInTeam(groupID, teamID) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("GroupStore.GetMemberUsersInTeam", success, elapsed) + } + return resultVar0, resultVar1 +} + +func (s *TimerLayerGroupStore) GetMemberUsersNotInChannel(groupID string, channelID string) ([]*model.User, *model.AppError) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.GroupStore.GetMemberUsersNotInChannel(groupID, channelID) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("GroupStore.GetMemberUsersNotInChannel", success, elapsed) + } + return resultVar0, resultVar1 +} + func (s *TimerLayerGroupStore) GetMemberUsersPage(groupID string, page int, perPage int) ([]*model.User, *model.AppError) { start := timemodule.Now()