From a78710c2a629b9cc37c1a8c289db820ed9faf4ed Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Mon, 23 Oct 2023 12:37:58 -0400 Subject: [PATCH] MM-54201 Refactor mention parsing in preparation for multi-word mentions (#25030) * MM-54201 Move ExplicitMentions to its own file and rename it (#24932) * MM-54201 Move ExplicitMentions to its own file and rename it * Fix vet * MM-54201 Refactor current mention parsing into MentionParserStandard (#24936) * MM-54201 Refactor current mention parsing into MentionParserStandard * Fix vet * MM-54201 Unify user and group mention parsing logic (#24937) * MM-54201 Add MentionKeywords type * MM-54201 Move group mentions into MentionKeywords * Fix flaky test caused by random iteration order * Update server/channels/app/mention_results.go Co-authored-by: Jesse Hallam * Address feedback --------- Co-authored-by: Jesse Hallam --------- Co-authored-by: Jesse Hallam --- server/channels/app/mention_keywords.go | 110 ++ server/channels/app/mention_keywords_test.go | 292 ++++ server/channels/app/mention_parser.go | 9 + .../channels/app/mention_parser_standard.go | 160 ++ .../app/mention_parser_standard_test.go | 433 ++++++ server/channels/app/mention_results.go | 91 ++ server/channels/app/mention_results_test.go | 64 + server/channels/app/notification.go | 335 +---- server/channels/app/notification_test.go | 1296 ++++++----------- server/channels/app/post.go | 18 +- .../app/post_persistent_notification.go | 33 +- 11 files changed, 1659 insertions(+), 1182 deletions(-) create mode 100644 server/channels/app/mention_keywords.go create mode 100644 server/channels/app/mention_keywords_test.go create mode 100644 server/channels/app/mention_parser.go create mode 100644 server/channels/app/mention_parser_standard.go create mode 100644 server/channels/app/mention_parser_standard_test.go create mode 100644 server/channels/app/mention_results.go create mode 100644 server/channels/app/mention_results_test.go diff --git a/server/channels/app/mention_keywords.go b/server/channels/app/mention_keywords.go new file mode 100644 index 0000000000..dafcc54d47 --- /dev/null +++ b/server/channels/app/mention_keywords.go @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "fmt" + "strings" + + "github.com/mattermost/mattermost/server/public/model" +) + +const ( + mentionableUserPrefix = "user:" + mentionableGroupPrefix = "group:" +) + +// A MentionableID stores the ID of a single User/Group with information about which type of object it refers to. +type MentionableID string + +func MentionableUserID(userID string) MentionableID { + return MentionableID(fmt.Sprint(mentionableUserPrefix, userID)) +} + +func MentionableGroupID(groupID string) MentionableID { + return MentionableID(fmt.Sprint(mentionableGroupPrefix, groupID)) +} + +func (id MentionableID) AsUserID() (userID string, ok bool) { + idString := string(id) + if !strings.HasPrefix(idString, mentionableUserPrefix) { + return "", false + } + + return idString[len(mentionableUserPrefix):], true +} + +func (id MentionableID) AsGroupID() (groupID string, ok bool) { + idString := string(id) + if !strings.HasPrefix(idString, mentionableGroupPrefix) { + return "", false + } + + return idString[len(mentionableGroupPrefix):], true +} + +// MentionKeywords is a collection of mention keywords and the IDs of the objects that have a given keyword. +type MentionKeywords map[string][]MentionableID + +func (k MentionKeywords) AddUser(profile *model.User, channelNotifyProps map[string]string, status *model.Status, allowChannelMentions bool) MentionKeywords { + mentionableID := MentionableUserID(profile.Id) + + userMention := "@" + strings.ToLower(profile.Username) + k[userMention] = append(k[userMention], mentionableID) + + // Add all the user's mention keys + for _, mentionKey := range profile.GetMentionKeys() { + if mentionKey != "" { + // Note that these are made lower case so that we can do a case insensitive check for them + mentionKey = strings.ToLower(mentionKey) + + k[mentionKey] = append(k[mentionKey], mentionableID) + } + } + + // If turned on, add the user's case sensitive first name + if profile.NotifyProps[model.FirstNameNotifyProp] == "true" && profile.FirstName != "" { + k[profile.FirstName] = append(k[profile.FirstName], mentionableID) + } + + // Add @channel and @all to k if user has them turned on and the server allows them + if allowChannelMentions { + // Ignore channel mentions if channel is muted and channel mention setting is default + ignoreChannelMentions := channelNotifyProps[model.IgnoreChannelMentionsNotifyProp] == model.IgnoreChannelMentionsOn || (channelNotifyProps[model.MarkUnreadNotifyProp] == model.UserNotifyMention && channelNotifyProps[model.IgnoreChannelMentionsNotifyProp] == model.IgnoreChannelMentionsDefault) + + if profile.NotifyProps[model.ChannelMentionsNotifyProp] == "true" && !ignoreChannelMentions { + k["@channel"] = append(k["@channel"], mentionableID) + k["@all"] = append(k["@all"], mentionableID) + + if status != nil && status.Status == model.StatusOnline { + k["@here"] = append(k["@here"], mentionableID) + } + } + } + + return k +} + +func (k MentionKeywords) AddUserKeyword(userID string, keyword string) MentionKeywords { + k[keyword] = append(k[keyword], MentionableUserID(userID)) + + return k +} + +func (k MentionKeywords) AddGroup(group *model.Group) MentionKeywords { + if group.Name != nil { + keyword := "@" + *group.Name + k[keyword] = append(k[keyword], MentionableGroupID(group.Id)) + } + + return k +} + +func (k MentionKeywords) AddGroupsMap(groups map[string]*model.Group) MentionKeywords { + for _, group := range groups { + k.AddGroup(group) + } + + return k +} diff --git a/server/channels/app/mention_keywords_test.go b/server/channels/app/mention_keywords_test.go new file mode 100644 index 0000000000..9e919bf707 --- /dev/null +++ b/server/channels/app/mention_keywords_test.go @@ -0,0 +1,292 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" +) + +func mapsToMentionKeywords(userKeywords map[string][]string, groups map[string]*model.Group) MentionKeywords { + keywords := make(MentionKeywords, len(userKeywords)+len(groups)) + + for keyword, ids := range userKeywords { + for _, id := range ids { + keywords[keyword] = append(keywords[keyword], MentionableUserID(id)) + } + } + + for _, group := range groups { + keyword := "@" + *group.Name + keywords[keyword] = append(keywords[keyword], MentionableGroupID(group.Id)) + } + + return keywords +} + +func TestMentionKeywords_AddUserProfile(t *testing.T) { + t.Run("should add @user", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + } + channelNotifyProps := map[string]string{} + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, nil, false) + + assert.Contains(t, keywords["@user"], MentionableUserID(user.Id)) + }) + + t.Run("should add custom mention keywords", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + NotifyProps: map[string]string{ + model.MentionKeysNotifyProp: "apple,BANANA,OrAnGe", + }, + } + channelNotifyProps := map[string]string{} + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, nil, false) + + assert.Contains(t, keywords["apple"], MentionableUserID(user.Id)) + assert.Contains(t, keywords["banana"], MentionableUserID(user.Id)) + assert.Contains(t, keywords["orange"], MentionableUserID(user.Id)) + }) + + t.Run("should not add empty custom keywords", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + NotifyProps: map[string]string{ + model.MentionKeysNotifyProp: ",,", + }, + } + channelNotifyProps := map[string]string{} + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, nil, false) + + assert.Nil(t, keywords[""]) + }) + + t.Run("should add case sensitive first name if enabled", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + FirstName: "William", + LastName: "Robert", + NotifyProps: map[string]string{ + model.FirstNameNotifyProp: "true", + }, + } + channelNotifyProps := map[string]string{} + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, nil, false) + + assert.Contains(t, keywords["William"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["william"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["Robert"], MentionableUserID(user.Id)) + }) + + t.Run("should not add case sensitive first name if enabled but empty First Name", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + FirstName: "", + LastName: "Robert", + NotifyProps: map[string]string{ + model.FirstNameNotifyProp: "true", + }, + } + channelNotifyProps := map[string]string{} + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, nil, false) + + assert.NotContains(t, keywords[""], MentionableUserID(user.Id)) + }) + + t.Run("should not add case sensitive first name if disabled", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + FirstName: "William", + LastName: "Robert", + NotifyProps: map[string]string{ + model.FirstNameNotifyProp: "false", + }, + } + channelNotifyProps := map[string]string{} + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, nil, false) + + assert.NotContains(t, keywords["William"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["william"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["Robert"], MentionableUserID(user.Id)) + }) + + t.Run("should add @channel/@all/@here when allowed", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + NotifyProps: map[string]string{ + model.ChannelMentionsNotifyProp: "true", + }, + } + channelNotifyProps := map[string]string{} + status := &model.Status{ + Status: model.StatusOnline, + } + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, status, true) + + assert.Contains(t, keywords["@channel"], MentionableUserID(user.Id)) + assert.Contains(t, keywords["@all"], MentionableUserID(user.Id)) + assert.Contains(t, keywords["@here"], MentionableUserID(user.Id)) + }) + + t.Run("should not add @channel/@all/@here when not allowed", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + NotifyProps: map[string]string{ + model.ChannelMentionsNotifyProp: "true", + }, + } + channelNotifyProps := map[string]string{} + status := &model.Status{ + Status: model.StatusOnline, + } + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, status, false) + + assert.NotContains(t, keywords["@channel"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@all"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@here"], MentionableUserID(user.Id)) + }) + + t.Run("should not add @channel/@all/@here when disabled for user", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + NotifyProps: map[string]string{ + model.ChannelMentionsNotifyProp: "false", + }, + } + channelNotifyProps := map[string]string{} + status := &model.Status{ + Status: model.StatusOnline, + } + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, status, true) + + assert.NotContains(t, keywords["@channel"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@all"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@here"], MentionableUserID(user.Id)) + }) + + t.Run("should not add @channel/@all/@here when disabled for channel", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + NotifyProps: map[string]string{ + model.ChannelMentionsNotifyProp: "true", + }, + } + channelNotifyProps := map[string]string{ + model.IgnoreChannelMentionsNotifyProp: model.IgnoreChannelMentionsOn, + } + status := &model.Status{ + Status: model.StatusOnline, + } + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, status, true) + + assert.NotContains(t, keywords["@channel"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@all"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@here"], MentionableUserID(user.Id)) + }) + + t.Run("should not add @channel/@all/@here when channel is muted and channel mention setting is not updated by user", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + NotifyProps: map[string]string{ + model.ChannelMentionsNotifyProp: "true", + }, + } + channelNotifyProps := map[string]string{ + model.MarkUnreadNotifyProp: model.UserNotifyMention, + model.IgnoreChannelMentionsNotifyProp: model.IgnoreChannelMentionsDefault, + } + status := &model.Status{ + Status: model.StatusOnline, + } + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, status, true) + + assert.NotContains(t, keywords["@channel"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@all"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@here"], MentionableUserID(user.Id)) + }) + + t.Run("should not add @here when when user is not online", func(t *testing.T) { + user := &model.User{ + Id: model.NewId(), + Username: "user", + NotifyProps: map[string]string{ + model.ChannelMentionsNotifyProp: "true", + }, + } + channelNotifyProps := map[string]string{} + status := &model.Status{ + Status: model.StatusAway, + } + + keywords := MentionKeywords{} + keywords.AddUser(user, channelNotifyProps, status, true) + + assert.Contains(t, keywords["@channel"], MentionableUserID(user.Id)) + assert.Contains(t, keywords["@all"], MentionableUserID(user.Id)) + assert.NotContains(t, keywords["@here"], MentionableUserID(user.Id)) + }) + + t.Run("should add for multiple users", func(t *testing.T) { + user1 := &model.User{ + Id: model.NewId(), + Username: "user1", + NotifyProps: map[string]string{ + model.ChannelMentionsNotifyProp: "true", + }, + } + user2 := &model.User{ + Id: model.NewId(), + Username: "user2", + NotifyProps: map[string]string{ + model.ChannelMentionsNotifyProp: "true", + }, + } + + keywords := MentionKeywords{} + keywords.AddUser(user1, map[string]string{}, nil, true) + keywords.AddUser(user2, map[string]string{}, nil, true) + + assert.Contains(t, keywords["@user1"], MentionableUserID(user1.Id)) + assert.Contains(t, keywords["@user2"], MentionableUserID(user2.Id)) + assert.Contains(t, keywords["@all"], MentionableUserID(user1.Id)) + assert.Contains(t, keywords["@all"], MentionableUserID(user2.Id)) + }) +} diff --git a/server/channels/app/mention_parser.go b/server/channels/app/mention_parser.go new file mode 100644 index 0000000000..cc53d59c4e --- /dev/null +++ b/server/channels/app/mention_parser.go @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +type MentionParser interface { + ProcessText(text string) + Results() *MentionResults +} diff --git a/server/channels/app/mention_parser_standard.go b/server/channels/app/mention_parser_standard.go new file mode 100644 index 0000000000..6a96cf1c3a --- /dev/null +++ b/server/channels/app/mention_parser_standard.go @@ -0,0 +1,160 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// Have the compiler confirm *StandardMentionParser implements MentionParser +var _ MentionParser = &StandardMentionParser{} + +type StandardMentionParser struct { + keywords MentionKeywords + + results *MentionResults +} + +func makeStandardMentionParser(keywords MentionKeywords) *StandardMentionParser { + return &StandardMentionParser{ + keywords: keywords, + + results: &MentionResults{}, + } +} + +// Processes text to filter mentioned users and other potential mentions +func (p *StandardMentionParser) ProcessText(text string) { + systemMentions := map[string]bool{"@here": true, "@channel": true, "@all": true} + + for _, word := range strings.FieldsFunc(text, func(c rune) bool { + // Split on any whitespace or punctuation that can't be part of an at mention or emoji pattern + return !(c == ':' || c == '.' || c == '-' || c == '_' || c == '@' || unicode.IsLetter(c) || unicode.IsNumber(c)) + }) { + // skip word with format ':word:' with an assumption that it is an emoji format only + if word[0] == ':' && word[len(word)-1] == ':' { + continue + } + + word = strings.TrimLeft(word, ":.-_") + + if p.checkForMention(word) { + continue + } + + foundWithoutSuffix := false + wordWithoutSuffix := word + + for wordWithoutSuffix != "" && strings.LastIndexAny(wordWithoutSuffix, ".-:_") == (len(wordWithoutSuffix)-1) { + wordWithoutSuffix = wordWithoutSuffix[0 : len(wordWithoutSuffix)-1] + + if p.checkForMention(wordWithoutSuffix) { + foundWithoutSuffix = true + break + } + } + + if foundWithoutSuffix { + continue + } + + if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { + // No need to bother about unicode as we are looking for ASCII characters. + last := word[len(word)-1] + switch last { + // If the word is possibly at the end of a sentence, remove that character. + case '.', '-', ':': + word = word[:len(word)-1] + } + p.results.OtherPotentialMentions = append(p.results.OtherPotentialMentions, word[1:]) + } else if strings.ContainsAny(word, ".-:") { + // This word contains a character that may be the end of a sentence, so split further + splitWords := strings.FieldsFunc(word, func(c rune) bool { + return c == '.' || c == '-' || c == ':' + }) + + for _, splitWord := range splitWords { + if p.checkForMention(splitWord) { + continue + } + if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") { + p.results.OtherPotentialMentions = append(p.results.OtherPotentialMentions, splitWord[1:]) + } + } + } + + if ids, match := isKeywordMultibyte(p.keywords, word); match { + p.addMentions(ids, KeywordMention) + } + } +} + +func (p *StandardMentionParser) Results() *MentionResults { + return p.results +} + +// checkForMention checks if there is a mention to a specific user or to the keywords here / channel / all +func (p *StandardMentionParser) checkForMention(word string) bool { + var mentionType MentionType + + switch strings.ToLower(word) { + case "@here": + p.results.HereMentioned = true + mentionType = ChannelMention + case "@channel": + p.results.ChannelMentioned = true + mentionType = ChannelMention + case "@all": + p.results.AllMentioned = true + mentionType = ChannelMention + default: + mentionType = KeywordMention + } + + if ids, match := p.keywords[strings.ToLower(word)]; match { + p.addMentions(ids, mentionType) + return true + } + + // Case-sensitive check for first name + if ids, match := p.keywords[word]; match { + p.addMentions(ids, mentionType) + return true + } + + return false +} + +func (p *StandardMentionParser) addMentions(ids []MentionableID, mentionType MentionType) { + for _, id := range ids { + if userID, ok := id.AsUserID(); ok { + p.results.addMention(userID, mentionType) + } else if groupID, ok := id.AsGroupID(); ok { + p.results.addGroupMention(groupID) + } + } +} + +// isKeywordMultibyte checks if a word containing a multibyte character contains a multibyte keyword +func isKeywordMultibyte(keywords MentionKeywords, word string) ([]MentionableID, bool) { + ids := []MentionableID{} + match := false + var multibyteKeywords []string + for keyword := range keywords { + if len(keyword) != utf8.RuneCountInString(keyword) { + multibyteKeywords = append(multibyteKeywords, keyword) + } + } + + if len(word) != utf8.RuneCountInString(word) { + for _, key := range multibyteKeywords { + if strings.Contains(word, key) { + ids, match = keywords[key] + } + } + } + return ids, match +} diff --git a/server/channels/app/mention_parser_standard_test.go b/server/channels/app/mention_parser_standard_test.go new file mode 100644 index 0000000000..471c3dc988 --- /dev/null +++ b/server/channels/app/mention_parser_standard_test.go @@ -0,0 +1,433 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" +) + +func TestIsKeywordMultibyte(t *testing.T) { + id1 := model.NewId() + + for name, tc := range map[string]struct { + Message string + Attachments []*model.SlackAttachment + Keywords map[string][]string + Groups map[string]*model.Group + Expected *MentionResults + }{ + "MultibyteCharacter": { + Message: "My name is 萌", + Keywords: map[string][]string{"萌": {id1}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id1: KeywordMention, + }, + }, + }, + "MultibyteCharacterWithNoUser": { + Message: "My name is 萌", + Keywords: map[string][]string{"萌": {}}, + Expected: &MentionResults{ + Mentions: nil, + }, + }, + "MultibyteCharacterAtBeginningOfSentence": { + Message: "이메일을 보내다.", + Keywords: map[string][]string{"이메일": {id1}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id1: KeywordMention, + }, + }, + }, + "MultibyteCharacterAtBeginningOfSentenceWithNoUser": { + Message: "이메일을 보내다.", + Keywords: map[string][]string{"이메일": {}}, + Expected: &MentionResults{ + Mentions: nil, + }, + }, + "MultibyteCharacterInPartOfSentence": { + Message: "我爱吃番茄炒饭", + Keywords: map[string][]string{"番茄": {id1}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id1: KeywordMention, + }, + }, + }, + "MultibyteCharacterInPartOfSentenceWithNoUser": { + Message: "我爱吃番茄炒饭", + Keywords: map[string][]string{"番茄": {}}, + Expected: &MentionResults{ + Mentions: nil, + }, + }, + "MultibyteCharacterAtEndOfSentence": { + Message: "こんにちは、世界", + Keywords: map[string][]string{"世界": {id1}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id1: KeywordMention, + }, + }, + }, + "MultibyteCharacterAtEndOfSentenceWithNoUser": { + Message: "こんにちは、世界", + Keywords: map[string][]string{"世界": {}}, + Expected: &MentionResults{ + Mentions: nil, + }, + }, + "MultibyteCharacterTwiceInSentence": { + Message: "石橋さんが石橋を渡る", + Keywords: map[string][]string{"石橋": {id1}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id1: KeywordMention, + }, + }, + }, + "MultibyteCharacterTwiceInSentenceWithNoUser": { + Message: "石橋さんが石橋を渡る", + Keywords: map[string][]string{"石橋": {}}, + Expected: &MentionResults{ + Mentions: nil, + }, + }, + } { + t.Run(name, func(t *testing.T) { + post := &model.Post{ + Message: tc.Message, + Props: model.StringInterface{ + "attachments": tc.Attachments, + }, + } + + m := getExplicitMentions(post, mapsToMentionKeywords(tc.Keywords, tc.Groups)) + assert.EqualValues(t, tc.Expected, m) + }) + } +} + +func TestCheckForMentionUsers(t *testing.T) { + id1 := model.NewId() + id2 := model.NewId() + + for name, tc := range map[string]struct { + Word string + Attachments []*model.SlackAttachment + Keywords map[string][]string + Expected *MentionResults + }{ + "Nobody": { + Word: "nothing", + Keywords: map[string][]string{}, + Expected: &MentionResults{}, + }, + "UppercaseUser1": { + Word: "@User", + Keywords: map[string][]string{"@user": {id1}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id1: KeywordMention, + }, + }, + }, + "LowercaseUser1": { + Word: "@user", + Keywords: map[string][]string{"@user": {id1}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id1: KeywordMention, + }, + }, + }, + "LowercaseUser2": { + Word: "@user2", + Keywords: map[string][]string{"@user2": {id2}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id2: KeywordMention, + }, + }, + }, + "UppercaseUser2": { + Word: "@UsEr2", + Keywords: map[string][]string{"@user2": {id2}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + id2: KeywordMention, + }, + }, + }, + "HereMention": { + Word: "@here", + Expected: &MentionResults{ + HereMentioned: true, + }, + }, + "ChannelMention": { + Word: "@channel", + Expected: &MentionResults{ + ChannelMentioned: true, + }, + }, + "AllMention": { + Word: "@all", + Expected: &MentionResults{ + AllMentioned: true, + }, + }, + "UppercaseHere": { + Word: "@HeRe", + Expected: &MentionResults{ + HereMentioned: true, + }, + }, + "UppercaseChannel": { + Word: "@ChaNNel", + Expected: &MentionResults{ + ChannelMentioned: true, + }, + }, + "UppercaseAll": { + Word: "@ALL", + Expected: &MentionResults{ + AllMentioned: true, + }, + }, + } { + t.Run(name, func(t *testing.T) { + p := makeStandardMentionParser(mapsToMentionKeywords(tc.Keywords, nil)) + p.checkForMention(tc.Word) + + assert.EqualValues(t, tc.Expected, p.Results()) + }) + } +} + +func TestCheckForMentionGroups(t *testing.T) { + groupID1 := model.NewId() + groupID2 := model.NewId() + + for name, tc := range map[string]struct { + Word string + Groups map[string]*model.Group + Expected *MentionResults + }{ + "No groups": { + Word: "nothing", + Groups: map[string]*model.Group{}, + Expected: &MentionResults{}, + }, + "No matching groups": { + Word: "nothing", + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{}, + }, + "matching group with no @": { + Word: "engineering", + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{}, + }, + "matching group with preceding @": { + Word: "@engineering", + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + GroupMentions: map[string]MentionType{ + groupID1: GroupMention, + }, + }, + }, + "matching upper case group with preceding @": { + Word: "@Engineering", + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + GroupMentions: map[string]MentionType{ + groupID1: GroupMention, + }, + }, + }, + } { + t.Run(name, func(t *testing.T) { + p := makeStandardMentionParser(mapsToMentionKeywords(nil, tc.Groups)) + p.checkForMention(tc.Word) + + mr := p.Results() + + assert.EqualValues(t, tc.Expected, mr) + }) + } +} + +func TestProcessText(t *testing.T) { + userID1 := model.NewId() + + groupID1 := model.NewId() + groupID2 := model.NewId() + + for name, tc := range map[string]struct { + Text string + Keywords map[string][]string + Groups map[string]*model.Group + Expected *MentionResults + }{ + "Mention user in text": { + Text: "hello user @user1", + Keywords: map[string][]string{"@user1": {userID1}}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + userID1: KeywordMention, + }, + }, + }, + "Mention user after ending a sentence with full stop": { + Text: "hello user.@user1", + Keywords: map[string][]string{"@user1": {userID1}}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + userID1: KeywordMention, + }, + }, + }, + "Mention user after hyphen": { + Text: "hello user-@user1", + Keywords: map[string][]string{"@user1": {userID1}}, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + userID1: KeywordMention, + }, + }, + }, + "Mention user after colon": { + Text: "hello user:@user1", + Keywords: map[string][]string{"@user1": {userID1}}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + userID1: KeywordMention, + }, + }, + }, + "Mention here after colon": { + Text: "hello all:@here", + Keywords: map[string][]string{}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + HereMentioned: true, + }, + }, + "Mention all after hyphen": { + Text: "hello all-@all", + Keywords: map[string][]string{}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + AllMentioned: true, + }, + }, + "Mention channel after full stop": { + Text: "hello channel.@channel", + Keywords: map[string][]string{}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + ChannelMentioned: true, + }, + }, + "Mention other potential users or system calls": { + Text: "hello @potentialuser and @otherpotentialuser", + Keywords: map[string][]string{}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + OtherPotentialMentions: []string{"potentialuser", "otherpotentialuser"}, + }, + }, + "Mention a real user and another potential user": { + Text: "@user1, you can use @systembot to get help", + Keywords: map[string][]string{"@user1": {userID1}}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + userID1: KeywordMention, + }, + OtherPotentialMentions: []string{"systembot"}, + }, + }, + "Mention a group": { + Text: "@engineering", + Keywords: map[string][]string{"@user1": {userID1}}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + GroupMentions: map[string]MentionType{groupID1: GroupMention}, + }, + }, + "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": {userID1}}, + Groups: map[string]*model.Group{ + groupID1: {Id: groupID1, Name: model.NewString("engineering")}, + groupID2: {Id: groupID2, Name: model.NewString("developers")}, + }, + Expected: &MentionResults{ + Mentions: map[string]MentionType{ + userID1: KeywordMention, + }, + GroupMentions: map[string]MentionType{groupID1: GroupMention}, + OtherPotentialMentions: []string{"systembot"}, + }, + }, + } { + t.Run(name, func(t *testing.T) { + p := makeStandardMentionParser(mapsToMentionKeywords(tc.Keywords, tc.Groups)) + p.ProcessText(tc.Text) + + assert.EqualValues(t, tc.Expected, p.Results()) + }) + } +} diff --git a/server/channels/app/mention_results.go b/server/channels/app/mention_results.go new file mode 100644 index 0000000000..6f8e5eeaaf --- /dev/null +++ b/server/channels/app/mention_results.go @@ -0,0 +1,91 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +const ( + // Different types of mentions ordered by their priority from lowest to highest + + // A placeholder that should never be used in practice + NoMention MentionType = iota + + // The post is in a GM + GMMention + + // The post is in a thread that the user has commented on + ThreadMention + + // The post is a comment on a thread started by the user + CommentMention + + // The post contains an at-channel, at-all, or at-here + ChannelMention + + // The post is a DM + DMMention + + // The post contains an at-mention for the user + KeywordMention + + // The post contains a group mention for the user + GroupMention +) + +type MentionType int + +type MentionResults struct { + // Mentions maps the ID of each user that was mentioned to how they were mentioned. + Mentions map[string]MentionType + + // GroupMentions maps the ID of each group that was mentioned to how it was mentioned. + GroupMentions map[string]MentionType + + // OtherPotentialMentions contains a list of strings that looked like mentions, but didn't have + // a corresponding keyword. + OtherPotentialMentions []string + + // HereMentioned is true if the message contained @here. + HereMentioned bool + + // AllMentioned is true if the message contained @all. + AllMentioned bool + + // ChannelMentioned is true if the message contained @channel. + ChannelMentioned bool +} + +func (m *MentionResults) isUserMentioned(userID string) bool { + if _, ok := m.Mentions[userID]; ok { + return true + } + + if _, ok := m.GroupMentions[userID]; ok { + return true + } + + return m.HereMentioned || m.AllMentioned || m.ChannelMentioned +} + +func (m *MentionResults) addMention(userID string, mentionType MentionType) { + if m.Mentions == nil { + m.Mentions = make(map[string]MentionType) + } + + if currentType, ok := m.Mentions[userID]; ok && currentType >= mentionType { + return + } + + m.Mentions[userID] = mentionType +} + +func (m *MentionResults) removeMention(userID string) { + delete(m.Mentions, userID) +} + +func (m *MentionResults) addGroupMention(groupID string) { + if m.GroupMentions == nil { + m.GroupMentions = make(map[string]MentionType) + } + + m.GroupMentions[groupID] = GroupMention +} diff --git a/server/channels/app/mention_results_test.go b/server/channels/app/mention_results_test.go new file mode 100644 index 0000000000..da1f34d965 --- /dev/null +++ b/server/channels/app/mention_results_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" +) + +func TestAddMention(t *testing.T) { + t.Run("should initialize Mentions and store new mentions", func(t *testing.T) { + m := &MentionResults{} + + userID1 := model.NewId() + userID2 := model.NewId() + + m.addMention(userID1, KeywordMention) + m.addMention(userID2, CommentMention) + + assert.Equal(t, map[string]MentionType{ + userID1: KeywordMention, + userID2: CommentMention, + }, m.Mentions) + }) + + t.Run("should replace existing mentions with higher priority ones", func(t *testing.T) { + m := &MentionResults{} + + userID1 := model.NewId() + userID2 := model.NewId() + + m.addMention(userID1, ThreadMention) + m.addMention(userID2, DMMention) + + m.addMention(userID1, ChannelMention) + m.addMention(userID2, KeywordMention) + + assert.Equal(t, map[string]MentionType{ + userID1: ChannelMention, + userID2: KeywordMention, + }, m.Mentions) + }) + + t.Run("should not replace high priority mentions with low priority ones", func(t *testing.T) { + m := &MentionResults{} + + userID1 := model.NewId() + userID2 := model.NewId() + + m.addMention(userID1, KeywordMention) + m.addMention(userID2, CommentMention) + + m.addMention(userID1, DMMention) + m.addMention(userID2, ThreadMention) + + assert.Equal(t, map[string]MentionType{ + userID1: KeywordMention, + userID2: CommentMention, + }, m.Mentions) + }) +} diff --git a/server/channels/app/notification.go b/server/channels/app/notification.go index f2c76543f2..8696577104 100644 --- a/server/channels/app/notification.go +++ b/server/channels/app/notification.go @@ -10,8 +10,6 @@ import ( "sort" "strings" "sync" - "unicode" - "unicode/utf8" "github.com/pkg/errors" @@ -126,14 +124,15 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea var allActivityPushUserIds []string if channel.Type != model.ChannelTypeDirect { // Iterate through all groups that were mentioned and insert group members into the list of mentions or potential mentions - for _, group := range mentions.GroupMentions { + for groupID := range mentions.GroupMentions { + group := groups[groupID] anyUsersMentionedByGroup, err := a.insertGroupMentions(group, channel, profileMap, mentions) if err != nil { return nil, err } if !anyUsersMentionedByGroup { - a.sendNoUsersNotifiedByGroupInChannel(c, sender, post, channel, group) + a.sendNoUsersNotifiedByGroupInChannel(c, sender, post, channel, groups[groupID]) } } @@ -165,14 +164,14 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea membershipsMutex := &sync.Mutex{} followersMutex := &sync.Mutex{} if *a.Config().ServiceSettings.ThreadAutoFollow && post.RootId != "" { - var rootMentions *ExplicitMentions + var rootMentions *MentionResults if parentPostList != nil { rootPost := parentPostList.Posts[parentPostList.Order[0]] if rootPost.GetProp("from_webhook") != "true" { threadParticipants[rootPost.UserId] = true } if channel.Type != model.ChannelTypeDirect { - rootMentions = getExplicitMentions(rootPost, keywords, groups) + rootMentions = getExplicitMentions(rootPost, keywords) for id := range rootMentions.Mentions { threadParticipants[id] = true } @@ -676,9 +675,9 @@ func (a *App) RemoveNotifications(c request.CTX, post *model.Post, channel *mode mentions, _ := a.getExplicitMentionsAndKeywords(c, post, channel, profileMap, groups, channelMemberNotifyPropsMap, nil) userIDs := []string{} - for _, group := range mentions.GroupMentions { + for groupID := range mentions.GroupMentions { for page := 0; ; page++ { - groupMemberPage, count, appErr := a.GetGroupMemberUsersPage(group.Id, page, 100, &model.ViewUsersRestrictions{Channels: []string{channel.Id}}) + groupMemberPage, count, appErr := a.GetGroupMemberUsersPage(groupID, page, 100, &model.ViewUsersRestrictions{Channels: []string{channel.Id}}) if appErr != nil { return appErr } @@ -752,10 +751,10 @@ func (a *App) RemoveNotifications(c request.CTX, post *model.Post, channel *mode return nil } -func (a *App) getExplicitMentionsAndKeywords(c request.CTX, post *model.Post, channel *model.Channel, profileMap map[string]*model.User, groups map[string]*model.Group, channelMemberNotifyPropsMap map[string]model.StringMap, parentPostList *model.PostList) (*ExplicitMentions, map[string][]string) { - mentions := &ExplicitMentions{} +func (a *App) getExplicitMentionsAndKeywords(c request.CTX, post *model.Post, channel *model.Channel, profileMap map[string]*model.User, groups map[string]*model.Group, channelMemberNotifyPropsMap map[string]model.StringMap, parentPostList *model.PostList) (*MentionResults, MentionKeywords) { + mentions := &MentionResults{} var allowChannelMentions bool - var keywords map[string][]string + var keywords MentionKeywords if channel.Type == model.ChannelTypeDirect { otherUserId := channel.GetOtherUserIdForDM(post.UserId) @@ -770,9 +769,9 @@ func (a *App) getExplicitMentionsAndKeywords(c request.CTX, post *model.Post, ch } } else { allowChannelMentions = a.allowChannelMentions(c, post, len(profileMap)) - keywords = a.getMentionKeywordsInChannel(profileMap, allowChannelMentions, channelMemberNotifyPropsMap) + keywords = a.getMentionKeywordsInChannel(profileMap, allowChannelMentions, channelMemberNotifyPropsMap, groups) - mentions = getExplicitMentions(post, keywords, groups) + mentions = getExplicitMentions(post, keywords) // Add a GM mention to all members of a GM channel if channel.Type == model.ChannelTypeGroup { @@ -1045,141 +1044,39 @@ func splitAtFinal(items []string) (preliminary []string, final string) { return } -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 - - // HereMentioned is true if the message contained @here. - HereMentioned bool - - // AllMentioned is true if the message contained @all. - AllMentioned bool - - // ChannelMentioned is true if the message contained @channel. - ChannelMentioned bool -} - -type MentionType int - -const ( - // Different types of mentions ordered by their priority from lowest to highest - - // A placeholder that should never be used in practice - NoMention MentionType = iota - - // The post is in a GM - GMMention - - // The post is in a thread that the user has commented on - ThreadMention - - // The post is a comment on a thread started by the user - CommentMention - - // The post contains an at-channel, at-all, or at-here - ChannelMention - - // The post is a DM - DMMention - - // The post contains an at-mention for the user - KeywordMention - - // The post contains a group mention for the user - GroupMention -) - -func (m *ExplicitMentions) isUserMentioned(userID string) bool { - if _, ok := m.Mentions[userID]; ok { - return true - } - - if _, ok := m.GroupMentions[userID]; ok { - return true - } - - return m.HereMentioned || m.AllMentioned || m.ChannelMentioned -} - -func (m *ExplicitMentions) addMention(userID string, mentionType MentionType) { - if m.Mentions == nil { - m.Mentions = make(map[string]MentionType) - } - - if currentType, ok := m.Mentions[userID]; ok && currentType >= mentionType { - return - } - - 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) - } - - if group.Name != nil { - m.GroupMentions[*group.Name] = group - } - - return true -} - -func (m *ExplicitMentions) addMentions(userIDs []string, mentionType MentionType) { - for _, userID := range userIDs { - m.addMention(userID, mentionType) - } -} - -func (m *ExplicitMentions) removeMention(userID string) { - delete(m.Mentions, userID) -} - // 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, groups map[string]*model.Group) *ExplicitMentions { - ret := &ExplicitMentions{} +func getExplicitMentions(post *model.Post, keywords MentionKeywords) *MentionResults { + parser := makeStandardMentionParser(keywords) buf := "" mentionsEnabledFields := getMentionsEnabledFields(post) for _, message := range mentionsEnabledFields { + // Parse the text as Markdown, combining adjacent Text nodes into a single string for processing markdown.Inspect(message, func(node any) bool { text, ok := node.(*markdown.Text) if !ok { - ret.processText(buf, keywords, groups) + // This node isn't a string so process any accumulated text in the buffer + if buf != "" { + parser.ProcessText(buf) + } + buf = "" return true } + + // This node is a string, so add it to buf and continue onto the next node to see if it's more text buf += text.Text return false }) } - ret.processText(buf, keywords, groups) - return ret + // Process any left over text + if buf != "" { + parser.ProcessText(buf) + } + + return parser.Results() } // Given a post returns the values of the fields in which mentions are possible. @@ -1251,7 +1148,7 @@ func (a *App) getGroupsAllowedForReferenceInChannel(channel *model.Channel, team } for _, group := range groups { if group.Group.Name != nil { - groupsMap[*group.Group.Name] = &group.Group + groupsMap[group.Id] = &group.Group } } return groupsMap, nil @@ -1263,7 +1160,7 @@ func (a *App) getGroupsAllowedForReferenceInChannel(channel *model.Channel, team } for _, group := range groups { if group.Name != nil { - groupsMap[*group.Name] = group + groupsMap[group.Id] = group } } @@ -1272,12 +1169,11 @@ func (a *App) getGroupsAllowedForReferenceInChannel(channel *model.Channel, team // 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 { - keywords := make(map[string][]string) +func (a *App) getMentionKeywordsInChannel(profiles map[string]*model.User, allowChannelMentions bool, channelMemberNotifyPropsMap map[string]model.StringMap, groups map[string]*model.Group) MentionKeywords { + keywords := make(MentionKeywords) for _, profile := range profiles { - addMentionKeywordsForUser( - keywords, + keywords.AddUser( profile, channelMemberNotifyPropsMap[profile.Id], a.GetStatusFromCache(profile.Id), @@ -1285,12 +1181,14 @@ func (a *App) getMentionKeywordsInChannel(profiles map[string]*model.User, allow ) } + keywords.AddGroupsMap(groups) + 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) { +func (a *App) insertGroupMentions(group *model.Group, channel *model.Channel, profileMap map[string]*model.User, mentions *MentionResults) (bool, *model.AppError) { var err error var groupMembers []*model.User outOfChannelGroupMembers := []*model.User{} @@ -1331,44 +1229,6 @@ func (a *App) insertGroupMentions(group *model.Group, channel *model.Channel, pr 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) - keywords[userMention] = append(keywords[userMention], profile.Id) - - // Add all the user's mention keys - for _, k := range profile.GetMentionKeys() { - // note that these are made lower case so that we can do a case insensitive check for them - key := strings.ToLower(k) - - if key != "" { - keywords[key] = append(keywords[key], profile.Id) - } - } - - // If turned on, add the user's case sensitive first name - if profile.NotifyProps[model.FirstNameNotifyProp] == "true" && profile.FirstName != "" { - keywords[profile.FirstName] = append(keywords[profile.FirstName], profile.Id) - } - - // Add @channel and @all to keywords if user has them turned on and the server allows them - if allowChannelMentions { - // Ignore channel mentions if channel is muted and channel mention setting is default - ignoreChannelMentions := channelNotifyProps[model.IgnoreChannelMentionsNotifyProp] == model.IgnoreChannelMentionsOn || (channelNotifyProps[model.MarkUnreadNotifyProp] == model.UserNotifyMention && channelNotifyProps[model.IgnoreChannelMentionsNotifyProp] == model.IgnoreChannelMentionsDefault) - - if profile.NotifyProps[model.ChannelMentionsNotifyProp] == "true" && !ignoreChannelMentions { - keywords["@channel"] = append(keywords["@channel"], profile.Id) - keywords["@all"] = append(keywords["@all"], profile.Id) - - if status != nil && status.Status == model.StatusOnline { - keywords["@here"] = append(keywords["@here"], profile.Id) - } - } - } - - return keywords -} - // Represents either an email or push notification and contains the fields required to send it to any user. type PostNotification struct { Channel *model.Channel @@ -1418,127 +1278,6 @@ func (n *PostNotification) GetSenderName(userNameFormat string, overridesAllowed return n.Sender.GetDisplayNameWithPrefix(userNameFormat, "@") } -// 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, groups map[string]*model.Group) bool { - var mentionType MentionType - - switch strings.ToLower(word) { - case "@here": - m.HereMentioned = true - mentionType = ChannelMention - case "@channel": - m.ChannelMentioned = true - mentionType = ChannelMention - case "@all": - m.AllMentioned = true - mentionType = ChannelMention - default: - mentionType = KeywordMention - } - - m.addGroupMention(word, groups) - - if ids, match := keywords[strings.ToLower(word)]; match { - m.addMentions(ids, mentionType) - return true - } - - // Case-sensitive check for first name - if ids, match := keywords[word]; match { - m.addMentions(ids, mentionType) - return true - } - - return false -} - -// isKeywordMultibyte checks if a word containing a multibyte character contains a multibyte keyword -func isKeywordMultibyte(keywords map[string][]string, word string) ([]string, bool) { - ids := []string{} - match := false - var multibyteKeywords []string - for keyword := range keywords { - if len(keyword) != utf8.RuneCountInString(keyword) { - multibyteKeywords = append(multibyteKeywords, keyword) - } - } - - if len(word) != utf8.RuneCountInString(word) { - for _, key := range multibyteKeywords { - if strings.Contains(word, key) { - ids, match = keywords[key] - } - } - } - return ids, match -} - -// Processes text to filter mentioned users and other potential mentions -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 { - // Split on any whitespace or punctuation that can't be part of an at mention or emoji pattern - return !(c == ':' || c == '.' || c == '-' || c == '_' || c == '@' || unicode.IsLetter(c) || unicode.IsNumber(c)) - }) { - // skip word with format ':word:' with an assumption that it is an emoji format only - if word[0] == ':' && word[len(word)-1] == ':' { - continue - } - - word = strings.TrimLeft(word, ":.-_") - - if m.checkForMention(word, keywords, groups) { - continue - } - - foundWithoutSuffix := false - wordWithoutSuffix := word - - for wordWithoutSuffix != "" && strings.LastIndexAny(wordWithoutSuffix, ".-:_") == (len(wordWithoutSuffix)-1) { - wordWithoutSuffix = wordWithoutSuffix[0 : len(wordWithoutSuffix)-1] - - if m.checkForMention(wordWithoutSuffix, keywords, groups) { - foundWithoutSuffix = true - break - } - } - - if foundWithoutSuffix { - continue - } - - if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { - // No need to bother about unicode as we are looking for ASCII characters. - last := word[len(word)-1] - switch last { - // If the word is possibly at the end of a sentence, remove that character. - case '.', '-', ':': - word = word[:len(word)-1] - } - m.OtherPotentialMentions = append(m.OtherPotentialMentions, word[1:]) - } else if strings.ContainsAny(word, ".-:") { - // This word contains a character that may be the end of a sentence, so split further - splitWords := strings.FieldsFunc(word, func(c rune) bool { - return c == '.' || c == '-' || c == ':' - }) - - for _, splitWord := range splitWords { - if m.checkForMention(splitWord, keywords, groups) { - continue - } - if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") { - m.OtherPotentialMentions = append(m.OtherPotentialMentions, splitWord[1:]) - } - } - } - - if ids, match := isKeywordMultibyte(keywords, word); match { - m.addMentions(ids, KeywordMention) - } - } -} - func (a *App) GetNotificationNameFormat(user *model.User) string { if !*a.Config().PrivacySettings.ShowFullName { return model.ShowUsername @@ -1563,7 +1302,7 @@ type CRTNotifiers struct { Push model.StringArray } -func (c *CRTNotifiers) addFollowerToNotify(user *model.User, mentions *ExplicitMentions, channelMemberNotificationProps model.StringMap, channel *model.Channel) { +func (c *CRTNotifiers) addFollowerToNotify(user *model.User, mentions *MentionResults, channelMemberNotificationProps model.StringMap, channel *model.Channel) { _, userWasMentioned := mentions.Mentions[user.Id] notifyDesktop, notifyPush, notifyEmail := shouldUserNotifyCRT(user, userWasMentioned) notifyChannelDesktop, notifyChannelPush := shouldChannelMemberNotifyCRT(channelMemberNotificationProps, userWasMentioned) diff --git a/server/channels/app/notification_test.go b/server/channels/app/notification_test.go index e0ebd504cd..8b0fc1ec7b 100644 --- a/server/channels/app/notification_test.go +++ b/server/channels/app/notification_test.go @@ -463,28 +463,30 @@ func TestGetExplicitMentions(t *testing.T) { id2 := model.NewId() id3 := model.NewId() + groupID1 := model.NewId() + for name, tc := range map[string]struct { Message string Attachments []*model.SlackAttachment Keywords map[string][]string Groups map[string]*model.Group - Expected *ExplicitMentions + Expected *MentionResults }{ "Nobody": { Message: "this is a message", Keywords: map[string][]string{}, - Expected: &ExplicitMentions{}, + Expected: &MentionResults{}, }, "NonexistentUser": { Message: "this is a message for @user", - Expected: &ExplicitMentions{ + Expected: &MentionResults{ OtherPotentialMentions: []string{"user"}, }, }, "OnePerson": { Message: "this is a message for @user", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -493,7 +495,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithPeriodAtEndOfUsername": { Message: "this is a message for @user.name.", Keywords: map[string][]string{"@user.name.": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -502,7 +504,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithPeriodAtEndOfUsernameButNotSimilarName": { Message: "this is a message for @user.name.", Keywords: map[string][]string{"@user.name.": {id1}, "@user.name": {id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -511,7 +513,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonAtEndOfSentence": { Message: "this is a message for @user.", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -520,7 +522,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithoutAtMention": { Message: "this is a message for @user", Keywords: map[string][]string{"this": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -530,7 +532,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithPeriodAfter": { Message: "this is a message for @user.", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -539,7 +541,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithPeriodBefore": { Message: "this is a message for .@user", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -548,7 +550,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithColonAfter": { Message: "this is a message for @user:", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -557,7 +559,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithColonBefore": { Message: "this is a message for :@user", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -566,7 +568,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithHyphenAfter": { Message: "this is a message for @user.", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -575,7 +577,7 @@ func TestGetExplicitMentions(t *testing.T) { "OnePersonWithHyphenBefore": { Message: "this is a message for -@user", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -584,7 +586,7 @@ func TestGetExplicitMentions(t *testing.T) { "MultiplePeopleWithOneWord": { Message: "this is a message for @user", Keywords: map[string][]string{"@user": {id1, id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, id2: KeywordMention, @@ -594,7 +596,7 @@ func TestGetExplicitMentions(t *testing.T) { "OneOfMultiplePeople": { Message: "this is a message for @user", Keywords: map[string][]string{"@user": {id1}, "@mention": {id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -603,7 +605,7 @@ func TestGetExplicitMentions(t *testing.T) { "MultiplePeopleWithMultipleWords": { Message: "this is an @mention for @user", Keywords: map[string][]string{"@user": {id1}, "@mention": {id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, id2: KeywordMention, @@ -613,7 +615,7 @@ func TestGetExplicitMentions(t *testing.T) { "Channel": { Message: "this is an message for @channel", Keywords: map[string][]string{"@channel": {id1, id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: ChannelMention, id2: ChannelMention, @@ -625,7 +627,7 @@ func TestGetExplicitMentions(t *testing.T) { "ChannelWithColonAtEnd": { Message: "this is a message for @channel:", Keywords: map[string][]string{"@channel": {id1, id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: ChannelMention, id2: ChannelMention, @@ -636,7 +638,7 @@ func TestGetExplicitMentions(t *testing.T) { "CapitalizedChannel": { Message: "this is an message for @cHaNNeL", Keywords: map[string][]string{"@channel": {id1, id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: ChannelMention, id2: ChannelMention, @@ -647,7 +649,7 @@ func TestGetExplicitMentions(t *testing.T) { "All": { Message: "this is an message for @all", Keywords: map[string][]string{"@all": {id1, id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: ChannelMention, id2: ChannelMention, @@ -658,7 +660,7 @@ func TestGetExplicitMentions(t *testing.T) { "AllWithColonAtEnd": { Message: "this is a message for @all:", Keywords: map[string][]string{"@all": {id1, id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: ChannelMention, id2: ChannelMention, @@ -669,7 +671,7 @@ func TestGetExplicitMentions(t *testing.T) { "CapitalizedAll": { Message: "this is an message for @ALL", Keywords: map[string][]string{"@all": {id1, id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: ChannelMention, id2: ChannelMention, @@ -680,7 +682,7 @@ func TestGetExplicitMentions(t *testing.T) { "UserWithPeriod": { Message: "user.period doesn't complicate things at all by including periods in their username", Keywords: map[string][]string{"user.period": {id1}, "user": {id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -689,7 +691,7 @@ func TestGetExplicitMentions(t *testing.T) { "AtUserWithColonAtEnd": { Message: "this is a message for @user:", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -698,7 +700,7 @@ func TestGetExplicitMentions(t *testing.T) { "AtUserWithPeriodAtEndOfSentence": { Message: "this is a message for @user.period.", Keywords: map[string][]string{"@user.period": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -707,7 +709,7 @@ func TestGetExplicitMentions(t *testing.T) { "UserWithPeriodAtEndOfSentence": { Message: "this is a message for user.period.", Keywords: map[string][]string{"user.period": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -716,7 +718,7 @@ func TestGetExplicitMentions(t *testing.T) { "UserWithColonAtEnd": { Message: "this is a message for user:", Keywords: map[string][]string{"user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -725,7 +727,7 @@ func TestGetExplicitMentions(t *testing.T) { "PotentialOutOfChannelUser": { Message: "this is an message for @potential and @user", Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -734,24 +736,24 @@ func TestGetExplicitMentions(t *testing.T) { }, "PotentialOutOfChannelUserWithPeriod": { Message: "this is an message for @potential.user", - Expected: &ExplicitMentions{ + Expected: &MentionResults{ OtherPotentialMentions: []string{"potential.user"}, }, }, "InlineCode": { Message: "`this shouldn't mention @channel at all`", Keywords: map[string][]string{}, - Expected: &ExplicitMentions{}, + Expected: &MentionResults{}, }, "FencedCodeBlock": { Message: "```\nthis shouldn't mention @channel at all\n```", Keywords: map[string][]string{}, - Expected: &ExplicitMentions{}, + Expected: &MentionResults{}, }, "Emphasis": { Message: "*@aaa @bbb @ccc*", Keywords: map[string][]string{"@aaa": {id1}, "@bbb": {id2}, "@ccc": {id3}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, id2: KeywordMention, @@ -762,7 +764,7 @@ func TestGetExplicitMentions(t *testing.T) { "StrongEmphasis": { Message: "**@aaa @bbb @ccc**", Keywords: map[string][]string{"@aaa": {id1}, "@bbb": {id2}, "@ccc": {id3}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, id2: KeywordMention, @@ -773,7 +775,7 @@ func TestGetExplicitMentions(t *testing.T) { "Strikethrough": { Message: "~~@aaa @bbb @ccc~~", Keywords: map[string][]string{"@aaa": {id1}, "@bbb": {id2}, "@ccc": {id3}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, id2: KeywordMention, @@ -784,7 +786,7 @@ func TestGetExplicitMentions(t *testing.T) { "Heading": { Message: "### @aaa", Keywords: map[string][]string{"@aaa": {id1}, "@bbb": {id2}, "@ccc": {id3}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -793,7 +795,7 @@ func TestGetExplicitMentions(t *testing.T) { "BlockQuote": { Message: "> @aaa", Keywords: map[string][]string{"@aaa": {id1}, "@bbb": {id2}, "@ccc": {id3}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -802,12 +804,12 @@ func TestGetExplicitMentions(t *testing.T) { "Emoji": { Message: ":smile:", Keywords: map[string][]string{"smile": {id1}, "smiley": {id2}, "smiley_cat": {id3}}, - Expected: &ExplicitMentions{}, + Expected: &MentionResults{}, }, "NotEmoji": { Message: "smile", Keywords: map[string][]string{"smile": {id1}, "smiley": {id2}, "smiley_cat": {id3}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -816,7 +818,7 @@ func TestGetExplicitMentions(t *testing.T) { "UnclosedEmoji": { Message: ":smile", Keywords: map[string][]string{"smile": {id1}, "smiley": {id2}, "smiley_cat": {id3}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -825,7 +827,7 @@ func TestGetExplicitMentions(t *testing.T) { "UnopenedEmoji": { Message: "smile:", Keywords: map[string][]string{"smile": {id1}, "smiley": {id2}, "smiley_cat": {id3}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -834,24 +836,24 @@ func TestGetExplicitMentions(t *testing.T) { "IndentedCodeBlock": { Message: " this shouldn't mention @channel at all", Keywords: map[string][]string{}, - Expected: &ExplicitMentions{}, + Expected: &MentionResults{}, }, "LinkTitle": { Message: `[foo](this "shouldn't mention @channel at all")`, Keywords: map[string][]string{}, - Expected: &ExplicitMentions{}, + Expected: &MentionResults{}, }, "MalformedInlineCode": { Message: "`this should mention @channel``", Keywords: map[string][]string{}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ ChannelMentioned: true, }, }, "MultibyteCharacter": { Message: "My name is 萌", Keywords: map[string][]string{"萌": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -860,7 +862,7 @@ func TestGetExplicitMentions(t *testing.T) { "MultibyteCharacterAtBeginningOfSentence": { Message: "이메일을 보내다.", Keywords: map[string][]string{"이메일": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -869,7 +871,7 @@ func TestGetExplicitMentions(t *testing.T) { "MultibyteCharacterInPartOfSentence": { Message: "我爱吃番茄炒饭", Keywords: map[string][]string{"番茄": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -878,7 +880,7 @@ func TestGetExplicitMentions(t *testing.T) { "MultibyteCharacterAtEndOfSentence": { Message: "こんにちは、世界", Keywords: map[string][]string{"世界": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -887,7 +889,7 @@ func TestGetExplicitMentions(t *testing.T) { "MultibyteCharacterTwiceInSentence": { Message: "石橋さんが石橋を渡る", Keywords: map[string][]string{"石橋": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -899,7 +901,7 @@ func TestGetExplicitMentions(t *testing.T) { "Don't include potential mention that's part of an actual mention (without trailing period)": { Message: "this is an message for @user.name", Keywords: map[string][]string{"@user.name": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -908,7 +910,7 @@ func TestGetExplicitMentions(t *testing.T) { "Don't include potential mention that's part of an actual mention (with trailing period)": { Message: "this is an message for @user.name.", Keywords: map[string][]string{"@user.name": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -917,7 +919,7 @@ func TestGetExplicitMentions(t *testing.T) { "Don't include potential mention that's part of an actual mention (with multiple trailing periods)": { Message: "this is an message for @user.name...", Keywords: map[string][]string{"@user.name": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -926,7 +928,7 @@ func TestGetExplicitMentions(t *testing.T) { "Don't include potential mention that's part of an actual mention (containing and followed by multiple periods)": { Message: "this is an message for @user...name...", Keywords: map[string][]string{"@user...name": {id1}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, }, @@ -941,7 +943,7 @@ func TestGetExplicitMentions(t *testing.T) { }, }, Keywords: map[string][]string{"@user1": {id1}, "@user2": {id2}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: map[string]MentionType{ id1: KeywordMention, id2: KeywordMention, @@ -952,29 +954,29 @@ func TestGetExplicitMentions(t *testing.T) { "Name on keywords is a prefix of a mention": { Message: "@other @test-two", Keywords: map[string][]string{"@test": {model.NewId()}}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ OtherPotentialMentions: []string{"other", "test-two"}, }, }, "Name on mentions is a prefix of other mention": { Message: "@other-one @other @other-two", Keywords: nil, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ OtherPotentialMentions: []string{"other-one", "other", "other-two"}, }, }, "No groups": { Message: "@nothing", Groups: map[string]*model.Group{}, - Expected: &ExplicitMentions{ + Expected: &MentionResults{ Mentions: nil, OtherPotentialMentions: []string{"nothing"}, }, }, "No matching groups": { Message: "@nothing", - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}}, - Expected: &ExplicitMentions{ + Groups: map[string]*model.Group{groupID1: {Id: groupID1, Name: model.NewString("engineering")}}, + Expected: &MentionResults{ Mentions: nil, GroupMentions: nil, OtherPotentialMentions: []string{"nothing"}, @@ -982,8 +984,8 @@ func TestGetExplicitMentions(t *testing.T) { }, "matching group with no @": { Message: "engineering", - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}}, - Expected: &ExplicitMentions{ + Groups: map[string]*model.Group{groupID1: {Id: groupID1, Name: model.NewString("engineering")}}, + Expected: &MentionResults{ Mentions: nil, GroupMentions: nil, OtherPotentialMentions: nil, @@ -991,24 +993,22 @@ func TestGetExplicitMentions(t *testing.T) { }, "matching group with preceding @": { Message: "@engineering", - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}}, - Expected: &ExplicitMentions{ + Groups: map[string]*model.Group{groupID1: {Id: groupID1, Name: model.NewString("engineering")}}, + Expected: &MentionResults{ Mentions: nil, - GroupMentions: map[string]*model.Group{ - "engineering": {Name: model.NewString("engineering")}, + GroupMentions: map[string]MentionType{ + groupID1: GroupMention, }, - OtherPotentialMentions: []string{"engineering"}, }, }, "matching upper case group with preceding @": { Message: "@Engineering", - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}}, - Expected: &ExplicitMentions{ + Groups: map[string]*model.Group{groupID1: {Id: groupID1, Name: model.NewString("engineering")}}, + Expected: &MentionResults{ Mentions: nil, - GroupMentions: map[string]*model.Group{ - "engineering": {Name: model.NewString("engineering")}, + GroupMentions: map[string]MentionType{ + groupID1: GroupMention, }, - OtherPotentialMentions: []string{"Engineering"}, }, }, } { @@ -1020,7 +1020,7 @@ func TestGetExplicitMentions(t *testing.T) { }, } - m := getExplicitMentions(post, tc.Keywords, tc.Groups) + m := getExplicitMentions(post, mapsToMentionKeywords(tc.Keywords, tc.Groups)) assert.EqualValues(t, tc.Expected, m) }) @@ -1073,7 +1073,7 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { } for message, shouldMention := range cases { post := &model.Post{Message: message} - m := getExplicitMentions(post, nil, nil) + m := getExplicitMentions(post, 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\"") } @@ -1081,7 +1081,10 @@ 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}}, nil) + m := getExplicitMentions( + &model.Post{Message: "@here @user @potential"}, + mapsToMentionKeywords(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\"") @@ -1091,7 +1094,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}}, nil) + m := getExplicitMentions( + &model.Post{Message: "@potential. test"}, + mapsToMentionKeywords(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]) }) @@ -1206,6 +1212,7 @@ func TestGetMentionKeywords(t *testing.T) { "mention_keys": "User,@User,MENTION", }, } + mentionableUser1ID := MentionableUserID(user1.Id) channelMemberNotifyPropsMap1Off := map[string]model.StringMap{ user1.Id: { @@ -1214,18 +1221,18 @@ func TestGetMentionKeywords(t *testing.T) { } profiles := map[string]*model.User{user1.Id: user1} - mentions := th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap1Off) - require.Len(t, mentions, 3, "should've returned three mention keywords") + keywords := th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap1Off, nil) + require.Len(t, keywords, 3, "should've returned three mention keywords") - ids, ok := mentions["user"] + ids, ok := keywords["user"] require.True(t, ok) - require.Equal(t, user1.Id, ids[0], "should've returned mention key of user") - ids, ok = mentions["@user"] + require.Equal(t, mentionableUser1ID, ids[0], "should've returned mention key of user") + ids, ok = keywords["@user"] require.True(t, ok) - require.Equal(t, user1.Id, ids[0], "should've returned mention key of @user") - ids, ok = mentions["mention"] + require.Equal(t, mentionableUser1ID, ids[0], "should've returned mention key of @user") + ids, ok = keywords["mention"] require.True(t, ok) - require.Equal(t, user1.Id, ids[0], "should've returned mention key of mention") + require.Equal(t, mentionableUser1ID, ids[0], "should've returned mention key of mention") // user with first name mention enabled user2 := &model.User{ @@ -1236,6 +1243,7 @@ func TestGetMentionKeywords(t *testing.T) { "first_name": "true", }, } + mentionableUser2ID := MentionableUserID(user2.Id) channelMemberNotifyPropsMap2Off := map[string]model.StringMap{ user2.Id: { @@ -1244,12 +1252,12 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{user2.Id: user2} - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap2Off) - require.Len(t, mentions, 2, "should've returned two mention keyword") + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap2Off, nil) + require.Len(t, keywords, 2, "should've returned two mention keyword") - ids, ok = mentions["First"] + ids, ok = keywords["First"] require.True(t, ok) - require.Equal(t, user2.Id, ids[0], "should've returned mention key of First") + require.Equal(t, mentionableUser2ID, ids[0], "should've returned mention key of First") // user with @channel/@all mentions enabled user3 := &model.User{ @@ -1260,6 +1268,7 @@ func TestGetMentionKeywords(t *testing.T) { "channel": "true", }, } + mentionableUser3ID := MentionableUserID(user3.Id) // Channel-wide mentions are not ignored on channel level channelMemberNotifyPropsMap3Off := map[string]model.StringMap{ @@ -1268,14 +1277,14 @@ func TestGetMentionKeywords(t *testing.T) { }, } profiles = map[string]*model.User{user3.Id: user3} - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap3Off) - require.Len(t, mentions, 3, "should've returned three mention keywords") - ids, ok = mentions["@channel"] + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap3Off, nil) + require.Len(t, keywords, 3, "should've returned three mention keywords") + ids, ok = keywords["@channel"] require.True(t, ok) - require.Equal(t, user3.Id, ids[0], "should've returned mention key of @channel") - ids, ok = mentions["@all"] + require.Equal(t, mentionableUser3ID, ids[0], "should've returned mention key of @channel") + ids, ok = keywords["@all"] require.True(t, ok) - require.Equal(t, user3.Id, ids[0], "should've returned mention key of @all") + require.Equal(t, mentionableUser3ID, ids[0], "should've returned mention key of @all") // Channel member notify props is set to default channelMemberNotifyPropsMapDefault := map[string]model.StringMap{ @@ -1284,26 +1293,26 @@ func TestGetMentionKeywords(t *testing.T) { }, } profiles = map[string]*model.User{user3.Id: user3} - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMapDefault) - require.Len(t, mentions, 3, "should've returned three mention keywords") - ids, ok = mentions["@channel"] + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMapDefault, nil) + require.Len(t, keywords, 3, "should've returned three mention keywords") + ids, ok = keywords["@channel"] require.True(t, ok) - require.Equal(t, user3.Id, ids[0], "should've returned mention key of @channel") - ids, ok = mentions["@all"] + require.Equal(t, mentionableUser3ID, ids[0], "should've returned mention key of @channel") + ids, ok = keywords["@all"] require.True(t, ok) - require.Equal(t, user3.Id, ids[0], "should've returned mention key of @all") + require.Equal(t, mentionableUser3ID, ids[0], "should've returned mention key of @all") // Channel member notify props is empty channelMemberNotifyPropsMapEmpty := map[string]model.StringMap{} profiles = map[string]*model.User{user3.Id: user3} - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMapEmpty) - require.Len(t, mentions, 3, "should've returned three mention keywords") - ids, ok = mentions["@channel"] + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMapEmpty, nil) + require.Len(t, keywords, 3, "should've returned three mention keywords") + ids, ok = keywords["@channel"] require.True(t, ok) - require.Equal(t, user3.Id, ids[0], "should've returned mention key of @channel") - ids, ok = mentions["@all"] + require.Equal(t, mentionableUser3ID, ids[0], "should've returned mention key of @channel") + ids, ok = keywords["@all"] require.True(t, ok) - require.Equal(t, user3.Id, ids[0], "should've returned mention key of @all") + require.Equal(t, mentionableUser3ID, ids[0], "should've returned mention key of @all") // Channel-wide mentions are ignored channel level channelMemberNotifyPropsMap3On := map[string]model.StringMap{ @@ -1311,8 +1320,8 @@ func TestGetMentionKeywords(t *testing.T) { "ignore_channel_mentions": model.IgnoreChannelMentionsOn, }, } - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap3On) - require.NotEmpty(t, mentions, "should've not returned any keywords") + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap3On, nil) + require.NotEmpty(t, keywords, "should've not returned any keywords") // user with all types of mentions enabled user4 := &model.User{ @@ -1325,6 +1334,7 @@ func TestGetMentionKeywords(t *testing.T) { "channel": "true", }, } + mentionableUser4ID := MentionableUserID(user4.Id) // Channel-wide mentions are not ignored on channel level channelMemberNotifyPropsMap4Off := map[string]model.StringMap{ @@ -1334,26 +1344,26 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{user4.Id: user4} - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap4Off) - require.Len(t, mentions, 6, "should've returned six mention keywords") - ids, ok = mentions["user"] + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap4Off, nil) + require.Len(t, keywords, 6, "should've returned six mention keywords") + ids, ok = keywords["user"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of user") - ids, ok = mentions["@user"] + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of user") + ids, ok = keywords["@user"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of @user") - ids, ok = mentions["mention"] + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of @user") + ids, ok = keywords["mention"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of mention") - ids, ok = mentions["First"] + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of mention") + ids, ok = keywords["First"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of First") - ids, ok = mentions["@channel"] + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of First") + ids, ok = keywords["@channel"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of @channel") - ids, ok = mentions["@all"] + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of @channel") + ids, ok = keywords["@all"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of @all") + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of @all") // Channel-wide mentions are ignored on channel level channelMemberNotifyPropsMap4On := map[string]model.StringMap{ @@ -1361,22 +1371,23 @@ func TestGetMentionKeywords(t *testing.T) { "ignore_channel_mentions": model.IgnoreChannelMentionsOn, }, } - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap4On) - require.Len(t, mentions, 4, "should've returned four mention keywords") - ids, ok = mentions["user"] + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap4On, nil) + require.Len(t, keywords, 4, "should've returned four mention keywords") + ids, ok = keywords["user"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of user") - ids, ok = mentions["@user"] + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of user") + ids, ok = keywords["@user"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of @user") - ids, ok = mentions["mention"] + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of @user") + ids, ok = keywords["mention"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of mention") - ids, ok = mentions["First"] + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of mention") + ids, ok = keywords["First"] require.True(t, ok) - require.Equal(t, user4.Id, ids[0], "should've returned mention key of First") - dup_count := func(list []string) map[string]int { - duplicate_frequency := make(map[string]int) + require.Equal(t, mentionableUser4ID, ids[0], "should've returned mention key of First") + + dup_count := func(list []MentionableID) map[MentionableID]int { + duplicate_frequency := make(map[MentionableID]int) for _, item := range list { // check if the item/element exist in the duplicate_frequency map @@ -1415,78 +1426,78 @@ func TestGetMentionKeywords(t *testing.T) { "ignore_channel_mentions": model.IgnoreChannelMentionsOff, }, } - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap5Off) - require.Len(t, mentions, 6, "should've returned six mention keywords") - ids, ok = mentions["user"] + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMap5Off, nil) + require.Len(t, keywords, 6, "should've returned six mention keywords") + ids, ok = keywords["user"] require.True(t, ok) require.Len(t, ids, 2) - require.False(t, ids[0] != user1.Id && ids[1] != user1.Id, "should've mentioned user1 with user") - require.False(t, ids[0] != user4.Id && ids[1] != user4.Id, "should've mentioned user4 with user") - idsMap := dup_count(mentions["@user"]) + require.False(t, ids[0] != mentionableUser1ID && ids[1] != mentionableUser1ID, "should've mentioned user1 with user") + require.False(t, ids[0] != mentionableUser4ID && ids[1] != mentionableUser4ID, "should've mentioned user4 with user") + idsMap := dup_count(keywords["@user"]) require.True(t, ok) require.Len(t, idsMap, 4) - require.Equal(t, idsMap[user1.Id], 2, "should've mentioned user1 with @user") - require.Equal(t, idsMap[user4.Id], 2, "should've mentioned user4 with @user") + require.Equal(t, idsMap[mentionableUser1ID], 2, "should've mentioned user1 with @user") + require.Equal(t, idsMap[mentionableUser4ID], 2, "should've mentioned user4 with @user") - ids, ok = mentions["mention"] + ids, ok = keywords["mention"] require.True(t, ok) require.Len(t, ids, 2) - require.False(t, ids[0] != user1.Id && ids[1] != user1.Id, "should've mentioned user1 with mention") - require.False(t, ids[0] != user4.Id && ids[1] != user4.Id, "should've mentioned user4 with mention") - ids, ok = mentions["First"] + require.False(t, ids[0] != mentionableUser1ID && ids[1] != mentionableUser1ID, "should've mentioned user1 with mention") + require.False(t, ids[0] != mentionableUser4ID && ids[1] != mentionableUser4ID, "should've mentioned user4 with mention") + ids, ok = keywords["First"] require.True(t, ok) require.Len(t, ids, 2) - require.False(t, ids[0] != user2.Id && ids[1] != user2.Id, "should've mentioned user2 with First") - require.False(t, ids[0] != user4.Id && ids[1] != user4.Id, "should've mentioned user4 with First") - ids, ok = mentions["@channel"] + require.False(t, ids[0] != mentionableUser2ID && ids[1] != mentionableUser2ID, "should've mentioned user2 with First") + require.False(t, ids[0] != mentionableUser4ID && ids[1] != mentionableUser4ID, "should've mentioned user4 with First") + ids, ok = keywords["@channel"] require.True(t, ok) require.Len(t, ids, 2) - require.False(t, ids[0] != user3.Id && ids[1] != user3.Id, "should've mentioned user3 with @channel") - require.False(t, ids[0] != user4.Id && ids[1] != user4.Id, "should've mentioned user4 with @channel") - ids, ok = mentions["@all"] + require.False(t, ids[0] != mentionableUser3ID && ids[1] != mentionableUser3ID, "should've mentioned user3 with @channel") + require.False(t, ids[0] != mentionableUser4ID && ids[1] != mentionableUser4ID, "should've mentioned user4 with @channel") + ids, ok = keywords["@all"] require.True(t, ok) require.Len(t, ids, 2) - require.False(t, ids[0] != user3.Id && ids[1] != user3.Id, "should've mentioned user3 with @all") - require.False(t, ids[0] != user4.Id && ids[1] != user4.Id, "should've mentioned user4 with @all") + require.False(t, ids[0] != mentionableUser3ID && ids[1] != mentionableUser3ID, "should've mentioned user3 with @all") + require.False(t, ids[0] != mentionableUser4ID && ids[1] != mentionableUser4ID, "should've mentioned user4 with @all") // multiple users and more than MaxNotificationsPerChannel - mentions = th.App.getMentionKeywordsInChannel(profiles, false, channelMemberNotifyPropsMap4Off) - require.Len(t, mentions, 4, "should've returned four mention keywords") - _, ok = mentions["@channel"] + keywords = th.App.getMentionKeywordsInChannel(profiles, false, channelMemberNotifyPropsMap4Off, nil) + require.Len(t, keywords, 4, "should've returned four mention keywords") + _, ok = keywords["@channel"] require.False(t, ok, "should not have mentioned any user with @channel") - _, ok = mentions["@all"] + _, ok = keywords["@all"] require.False(t, ok, "should not have mentioned any user with @all") - _, ok = mentions["@here"] + _, ok = keywords["@here"] require.False(t, ok, "should not have mentioned any user with @here") // no special mentions profiles = map[string]*model.User{ user1.Id: user1, } - mentions = th.App.getMentionKeywordsInChannel(profiles, false, channelMemberNotifyPropsMap4Off) - require.Len(t, mentions, 3, "should've returned three mention keywords") - ids, ok = mentions["user"] + keywords = th.App.getMentionKeywordsInChannel(profiles, false, channelMemberNotifyPropsMap4Off, nil) + require.Len(t, keywords, 3, "should've returned three mention keywords") + ids, ok = keywords["user"] require.True(t, ok) require.Len(t, ids, 1) - require.Equal(t, user1.Id, ids[0], "should've mentioned user1 with user") - ids, ok = mentions["@user"] + require.Equal(t, mentionableUser1ID, ids[0], "should've mentioned user1 with user") + ids, ok = keywords["@user"] require.True(t, ok) require.Len(t, ids, 2) - require.Equal(t, user1.Id, ids[0], "should've mentioned user1 twice with @user") - require.Equal(t, user1.Id, ids[1], "should've mentioned user1 twice with @user") + require.Equal(t, mentionableUser1ID, ids[0], "should've mentioned user1 twice with @user") + require.Equal(t, mentionableUser1ID, ids[1], "should've mentioned user1 twice with @user") - ids, ok = mentions["mention"] + ids, ok = keywords["mention"] require.True(t, ok) require.Len(t, ids, 1) - require.Equal(t, user1.Id, ids[0], "should've mentioned user1 with user") + require.Equal(t, mentionableUser1ID, ids[0], "should've mentioned user1 with user") - _, ok = mentions["First"] + _, ok = keywords["First"] require.False(t, ok, "should not have mentioned user1 with First") - _, ok = mentions["@channel"] + _, ok = keywords["@channel"] require.False(t, ok, "should not have mentioned any user with @channel") - _, ok = mentions["@all"] + _, ok = keywords["@all"] require.False(t, ok, "should not have mentioned any user with @all") - _, ok = mentions["@here"] + _, ok = keywords["@here"] require.False(t, ok, "should not have mentioned any user with @here") // user with empty mention keys @@ -1506,275 +1517,260 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{userNoMentionKeys.Id: userNoMentionKeys} - mentions = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMapEmptyOff) - assert.Equal(t, 1, len(mentions), "should've returned one mention keyword") - ids, ok = mentions["@user"] + keywords = th.App.getMentionKeywordsInChannel(profiles, true, channelMemberNotifyPropsMapEmptyOff, nil) + assert.Equal(t, 1, len(keywords), "should've returned one mention keyword") + ids, ok = keywords["@user"] assert.True(t, ok) - assert.Equal(t, userNoMentionKeys.Id, ids[0], "should've returned mention key of @user") + assert.Equal(t, MentionableUserID(userNoMentionKeys.Id), ids[0], "should've returned mention key of @user") } -func TestAddMentionKeywordsForUser(t *testing.T) { - t.Run("should add @user", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - } - channelNotifyProps := map[string]string{} +func TestGetMentionKeywords_Groups(t *testing.T) { + th := Setup(t) + defer th.TearDown() - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, nil, false) + userID1 := model.NewId() + userID2 := model.NewId() + mentionableUserID1 := MentionableUserID(userID1) + mentionableUserID2 := MentionableUserID(userID2) - assert.Contains(t, keywords["@user"], user.Id) - }) - - t.Run("should add custom mention keywords", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - NotifyProps: map[string]string{ - model.MentionKeysNotifyProp: "apple,BANANA,OrAnGe", + for name, tc := range map[string]struct { + Profiles map[string]*model.User + DisallowChannelMentions bool + ChannelMemberNotifyProps map[string]model.StringMap + Groups map[string]*model.Group + Expected MentionKeywords + }{ + "user with username or custom mentions enabled": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "mention_keys": "User,@User,MENTION", + }, + }, }, - } - channelNotifyProps := map[string]string{} - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, nil, false) - - assert.Contains(t, keywords["apple"], user.Id) - assert.Contains(t, keywords["banana"], user.Id) - assert.Contains(t, keywords["orange"], user.Id) - }) - - t.Run("should not add empty custom keywords", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - NotifyProps: map[string]string{ - model.MentionKeysNotifyProp: ",,", + Expected: MentionKeywords{ + "user": {mentionableUserID1}, + "@user": {mentionableUserID1, mentionableUserID1}, // lol + "mention": {mentionableUserID1}, }, - } - channelNotifyProps := map[string]string{} - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, nil, false) - - assert.Nil(t, keywords[""]) - }) - - t.Run("should add case sensitive first name if enabled", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - FirstName: "William", - LastName: "Robert", - NotifyProps: map[string]string{ - model.FirstNameNotifyProp: "true", + }, + "user with first name mentioned": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "first_name": "true", + }, + }, }, - } - channelNotifyProps := map[string]string{} - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, nil, false) - - assert.Contains(t, keywords["William"], user.Id) - assert.NotContains(t, keywords["william"], user.Id) - assert.NotContains(t, keywords["Robert"], user.Id) - }) - - t.Run("should not add case sensitive first name if enabled but empty First Name", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - FirstName: "", - LastName: "Robert", - NotifyProps: map[string]string{ - model.FirstNameNotifyProp: "true", + Expected: MentionKeywords{ + "First": {mentionableUserID1}, + "@user": {mentionableUserID1}, }, - } - channelNotifyProps := map[string]string{} - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, nil, false) - - assert.NotContains(t, keywords[""], user.Id) - }) - - t.Run("should not add case sensitive first name if disabled", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - FirstName: "William", - LastName: "Robert", - NotifyProps: map[string]string{ - model.FirstNameNotifyProp: "false", + }, + "user with @channel/@all mentions enabled account-wide": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "channel": "true", + }, + }, }, - } - channelNotifyProps := map[string]string{} - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, nil, false) - - assert.NotContains(t, keywords["William"], user.Id) - assert.NotContains(t, keywords["william"], user.Id) - assert.NotContains(t, keywords["Robert"], user.Id) - }) - - t.Run("should add @channel/@all/@here when allowed", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - NotifyProps: map[string]string{ - model.ChannelMentionsNotifyProp: "true", + ChannelMemberNotifyProps: map[string]model.StringMap{ + userID1: { + "ignore_channel_mentions": model.IgnoreChannelMentionsOff, + }, }, - } - channelNotifyProps := map[string]string{} - status := &model.Status{ - Status: model.StatusOnline, - } - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, status, true) - - assert.Contains(t, keywords["@channel"], user.Id) - assert.Contains(t, keywords["@all"], user.Id) - assert.Contains(t, keywords["@here"], user.Id) - }) - - t.Run("should not add @channel/@all/@here when not allowed", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - NotifyProps: map[string]string{ - model.ChannelMentionsNotifyProp: "true", + Expected: MentionKeywords{ + "@all": {mentionableUserID1}, + "@channel": {mentionableUserID1}, + "@user": {mentionableUserID1}, }, - } - channelNotifyProps := map[string]string{} - status := &model.Status{ - Status: model.StatusOnline, - } - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, status, false) - - assert.NotContains(t, keywords["@channel"], user.Id) - assert.NotContains(t, keywords["@all"], user.Id) - assert.NotContains(t, keywords["@here"], user.Id) - }) - - t.Run("should not add @channel/@all/@here when disabled for user", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - NotifyProps: map[string]string{ - model.ChannelMentionsNotifyProp: "false", + }, + "user with @channel/@all mentions enabled account-wide and default notify props": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "channel": "true", + }, + }, }, - } - channelNotifyProps := map[string]string{} - status := &model.Status{ - Status: model.StatusOnline, - } - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, status, true) - - assert.NotContains(t, keywords["@channel"], user.Id) - assert.NotContains(t, keywords["@all"], user.Id) - assert.NotContains(t, keywords["@here"], user.Id) - }) - - t.Run("should not add @channel/@all/@here when disabled for channel", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - NotifyProps: map[string]string{ - model.ChannelMentionsNotifyProp: "true", + ChannelMemberNotifyProps: map[string]model.StringMap{ + userID1: { + "ignore_channel_mentions": model.IgnoreChannelMentionsDefault, + }, }, - } - channelNotifyProps := map[string]string{ - model.IgnoreChannelMentionsNotifyProp: model.IgnoreChannelMentionsOn, - } - status := &model.Status{ - Status: model.StatusOnline, - } - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, status, true) - - assert.NotContains(t, keywords["@channel"], user.Id) - assert.NotContains(t, keywords["@all"], user.Id) - assert.NotContains(t, keywords["@here"], user.Id) - }) - - t.Run("should not add @channel/@all/@here when channel is muted and channel mention setting is not updated by user", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - NotifyProps: map[string]string{ - model.ChannelMentionsNotifyProp: "true", + Expected: MentionKeywords{ + "@all": {mentionableUserID1}, + "@channel": {mentionableUserID1}, + "@user": {mentionableUserID1}, }, - } - channelNotifyProps := map[string]string{ - model.MarkUnreadNotifyProp: model.UserNotifyMention, - model.IgnoreChannelMentionsNotifyProp: model.IgnoreChannelMentionsDefault, - } - status := &model.Status{ - Status: model.StatusOnline, - } - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, status, true) - - assert.NotContains(t, keywords["@channel"], user.Id) - assert.NotContains(t, keywords["@all"], user.Id) - assert.NotContains(t, keywords["@here"], user.Id) - }) - - t.Run("should not add @here when when user is not online", func(t *testing.T) { - user := &model.User{ - Id: model.NewId(), - Username: "user", - NotifyProps: map[string]string{ - model.ChannelMentionsNotifyProp: "true", + }, + "user with @channel/@all mentions enabled account-wide and empty channel notify props": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "channel": "true", + }, + }, }, - } - channelNotifyProps := map[string]string{} - status := &model.Status{ - Status: model.StatusAway, - } - - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user, channelNotifyProps, status, true) - - assert.Contains(t, keywords["@channel"], user.Id) - assert.Contains(t, keywords["@all"], user.Id) - assert.NotContains(t, keywords["@here"], user.Id) - }) - - t.Run("should add for multiple users", func(t *testing.T) { - user1 := &model.User{ - Id: model.NewId(), - Username: "user1", - NotifyProps: map[string]string{ - model.ChannelMentionsNotifyProp: "true", + Expected: MentionKeywords{ + "@all": {mentionableUserID1}, + "@channel": {mentionableUserID1}, + "@user": {mentionableUserID1}, }, - } - user2 := &model.User{ - Id: model.NewId(), - Username: "user2", - NotifyProps: map[string]string{ - model.ChannelMentionsNotifyProp: "true", + }, + "user with @channel/@all mentions enabled account-wide but ignored for channel": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "channel": "true", + }, + }, }, - } + ChannelMemberNotifyProps: map[string]model.StringMap{ + userID1: { + "ignore_channel_mentions": model.IgnoreChannelMentionsOn, + }, + }, + Expected: MentionKeywords{ + "@user": {mentionableUserID1}, + }, + }, + "user with all types of mentions enabled": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "mention_keys": "User,@User,MENTION", + "first_name": "true", + "channel": "true", + }, + }, + }, + ChannelMemberNotifyProps: map[string]model.StringMap{ + userID1: { + "ignore_channel_mentions": model.IgnoreChannelMentionsOff, + }, + }, + Expected: MentionKeywords{ + "@all": {mentionableUserID1}, + "@channel": {mentionableUserID1}, + "@user": {mentionableUserID1, mentionableUserID1}, + "First": {mentionableUserID1}, + "mention": {mentionableUserID1}, + "user": {mentionableUserID1}, + }, + }, + "user with all types of mentions enabled but channel mentions ignored for channel": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "mention_keys": "User,@User,MENTION", + "first_name": "true", + "channel": "true", + }, + }, + }, + ChannelMemberNotifyProps: map[string]model.StringMap{ + userID1: { + "ignore_channel_mentions": model.IgnoreChannelMentionsOn, + }, + }, + Expected: MentionKeywords{ + "@user": {mentionableUserID1, mentionableUserID1}, + "First": {mentionableUserID1}, + "mention": {mentionableUserID1}, + "user": {mentionableUserID1}, + }, + }, + "multiple users": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "mention_keys": "User,@User,MENTION", + "first_name": "true", + "channel": "true", + }, + }, + userID2: { + Id: userID2, + FirstName: "Second", + Username: "UserTwo", + NotifyProps: map[string]string{ + "mention_keys": "UserTwo,@UserTwo", + "first_name": "true", + "channel": "true", + }, + }, + }, + Expected: MentionKeywords{ + "@all": {mentionableUserID1, mentionableUserID2}, + "@channel": {mentionableUserID1, mentionableUserID2}, + "@user": {mentionableUserID1, mentionableUserID1}, + "@usertwo": {mentionableUserID2, mentionableUserID2}, + "First": {mentionableUserID1}, + "mention": {mentionableUserID1}, + "Second": {mentionableUserID2}, + "user": {mentionableUserID1}, + "usertwo": {mentionableUserID2}, + }, + }, + "user with empty mention keys": { + Profiles: map[string]*model.User{ + userID1: { + Id: userID1, + FirstName: "First", + Username: "User", + NotifyProps: map[string]string{ + "mention_keys": ",", + }, + }, + }, + Expected: MentionKeywords{ + "@user": {mentionableUserID1}, + }, + }, + } { + t.Run(name, func(t *testing.T) { + keywords := th.App.getMentionKeywordsInChannel( + tc.Profiles, + !tc.DisallowChannelMentions, + tc.ChannelMemberNotifyProps, + tc.Groups, + ) - keywords := map[string][]string{} - addMentionKeywordsForUser(keywords, user1, map[string]string{}, nil, true) - addMentionKeywordsForUser(keywords, user2, map[string]string{}, nil, true) - - assert.Contains(t, keywords["@user1"], user1.Id) - assert.Contains(t, keywords["@user2"], user2.Id) - assert.Contains(t, keywords["@all"], user1.Id) - assert.Contains(t, keywords["@all"], user2.Id) - }) + require.Equal(t, len(tc.Expected), len(keywords)) + for keyword, ids := range tc.Expected { + assert.ElementsMatch(t, ids, keywords[keyword]) + } + }) + } } func TestGetMentionsEnabledFields(t *testing.T) { @@ -1995,428 +1991,6 @@ func TestPostNotificationGetSenderName(t *testing.T) { } } -func TestIsKeywordMultibyte(t *testing.T) { - id1 := model.NewId() - - for name, tc := range map[string]struct { - Message string - Attachments []*model.SlackAttachment - Keywords map[string][]string - Groups map[string]*model.Group - Expected *ExplicitMentions - }{ - "MultibyteCharacter": { - Message: "My name is 萌", - Keywords: map[string][]string{"萌": {id1}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "MultibyteCharacterWithNoUser": { - Message: "My name is 萌", - Keywords: map[string][]string{"萌": {}}, - Expected: &ExplicitMentions{ - Mentions: nil, - }, - }, - "MultibyteCharacterAtBeginningOfSentence": { - Message: "이메일을 보내다.", - Keywords: map[string][]string{"이메일": {id1}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "MultibyteCharacterAtBeginningOfSentenceWithNoUser": { - Message: "이메일을 보내다.", - Keywords: map[string][]string{"이메일": {}}, - Expected: &ExplicitMentions{ - Mentions: nil, - }, - }, - "MultibyteCharacterInPartOfSentence": { - Message: "我爱吃番茄炒饭", - Keywords: map[string][]string{"番茄": {id1}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "MultibyteCharacterInPartOfSentenceWithNoUser": { - Message: "我爱吃番茄炒饭", - Keywords: map[string][]string{"番茄": {}}, - Expected: &ExplicitMentions{ - Mentions: nil, - }, - }, - "MultibyteCharacterAtEndOfSentence": { - Message: "こんにちは、世界", - Keywords: map[string][]string{"世界": {id1}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "MultibyteCharacterAtEndOfSentenceWithNoUser": { - Message: "こんにちは、世界", - Keywords: map[string][]string{"世界": {}}, - Expected: &ExplicitMentions{ - Mentions: nil, - }, - }, - "MultibyteCharacterTwiceInSentence": { - Message: "石橋さんが石橋を渡る", - Keywords: map[string][]string{"石橋": {id1}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "MultibyteCharacterTwiceInSentenceWithNoUser": { - Message: "石橋さんが石橋を渡る", - Keywords: map[string][]string{"石橋": {}}, - Expected: &ExplicitMentions{ - Mentions: nil, - }, - }, - } { - t.Run(name, func(t *testing.T) { - post := &model.Post{ - Message: tc.Message, - Props: model.StringInterface{ - "attachments": tc.Attachments, - }, - } - - m := getExplicitMentions(post, tc.Keywords, tc.Groups) - assert.EqualValues(t, tc.Expected, m) - }) - } -} - -func TestAddMention(t *testing.T) { - t.Run("should initialize Mentions and store new mentions", func(t *testing.T) { - m := &ExplicitMentions{} - - userID1 := model.NewId() - userID2 := model.NewId() - - m.addMention(userID1, KeywordMention) - m.addMention(userID2, CommentMention) - - assert.Equal(t, map[string]MentionType{ - userID1: KeywordMention, - userID2: CommentMention, - }, m.Mentions) - }) - - t.Run("should replace existing mentions with higher priority ones", func(t *testing.T) { - m := &ExplicitMentions{} - - userID1 := model.NewId() - userID2 := model.NewId() - - m.addMention(userID1, ThreadMention) - m.addMention(userID2, DMMention) - - m.addMention(userID1, ChannelMention) - m.addMention(userID2, KeywordMention) - - assert.Equal(t, map[string]MentionType{ - userID1: ChannelMention, - userID2: KeywordMention, - }, m.Mentions) - }) - - t.Run("should not replace high priority mentions with low priority ones", func(t *testing.T) { - m := &ExplicitMentions{} - - userID1 := model.NewId() - userID2 := model.NewId() - - m.addMention(userID1, KeywordMention) - m.addMention(userID2, CommentMention) - - m.addMention(userID1, DMMention) - m.addMention(userID2, ThreadMention) - - assert.Equal(t, map[string]MentionType{ - userID1: KeywordMention, - userID2: CommentMention, - }, m.Mentions) - }) -} - -func TestCheckForMentionUsers(t *testing.T) { - id1 := model.NewId() - id2 := model.NewId() - - for name, tc := range map[string]struct { - Word string - Attachments []*model.SlackAttachment - Keywords map[string][]string - Expected *ExplicitMentions - }{ - "Nobody": { - Word: "nothing", - Keywords: map[string][]string{}, - Expected: &ExplicitMentions{}, - }, - "UppercaseUser1": { - Word: "@User", - Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "LowercaseUser1": { - Word: "@user", - Keywords: map[string][]string{"@user": {id1}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "LowercaseUser2": { - Word: "@user2", - Keywords: map[string][]string{"@user2": {id2}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id2: KeywordMention, - }, - }, - }, - "UppercaseUser2": { - Word: "@UsEr2", - Keywords: map[string][]string{"@user2": {id2}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id2: KeywordMention, - }, - }, - }, - "HereMention": { - Word: "@here", - Expected: &ExplicitMentions{ - HereMentioned: true, - }, - }, - "ChannelMention": { - Word: "@channel", - Expected: &ExplicitMentions{ - ChannelMentioned: true, - }, - }, - "AllMention": { - Word: "@all", - Expected: &ExplicitMentions{ - AllMentioned: true, - }, - }, - "UppercaseHere": { - Word: "@HeRe", - Expected: &ExplicitMentions{ - HereMentioned: true, - }, - }, - "UppercaseChannel": { - Word: "@ChaNNel", - Expected: &ExplicitMentions{ - ChannelMentioned: true, - }, - }, - "UppercaseAll": { - Word: "@ALL", - Expected: &ExplicitMentions{ - AllMentioned: true, - }, - }, - } { - t.Run(name, func(t *testing.T) { - e := &ExplicitMentions{} - 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: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: false, - }, - "matching group with no @": { - Word: "engineering", - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: false, - }, - "matching group with preceding @": { - Word: "@engineering", - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: true, - }, - "matching upper case group with preceding @": { - Word: "@Engineering", - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("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: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "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: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "Mention user after hyphen": { - Text: "hello user-@user1", - Keywords: map[string][]string{"@user1": {id1}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "Mention user after colon": { - Text: "hello user:@user1", - Keywords: map[string][]string{"@user1": {id1}}, - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - }, - }, - "Mention here after colon": { - Text: "hello all:@here", - Keywords: map[string][]string{}, - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - HereMentioned: true, - }, - }, - "Mention all after hyphen": { - Text: "hello all-@all", - Keywords: map[string][]string{}, - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - AllMentioned: true, - }, - }, - "Mention channel after full stop": { - Text: "hello channel.@channel", - Keywords: map[string][]string{}, - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - ChannelMentioned: true, - }, - }, - "Mention other potential users or system calls": { - Text: "hello @potentialuser and @otherpotentialuser", - Keywords: map[string][]string{}, - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - OtherPotentialMentions: []string{"potentialuser", "otherpotentialuser"}, - }, - }, - "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: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - OtherPotentialMentions: []string{"systembot"}, - }, - }, - "Mention a group": { - Text: "@engineering", - Keywords: map[string][]string{"@user1": {id1}}, - Groups: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - GroupMentions: map[string]*model.Group{"engineering": {Name: model.NewString("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: model.NewString("engineering")}, "developers": {Name: model.NewString("developers")}}, - Expected: &ExplicitMentions{ - Mentions: map[string]MentionType{ - id1: KeywordMention, - }, - GroupMentions: map[string]*model.Group{"engineering": {Name: model.NewString("engineering")}}, - OtherPotentialMentions: []string{"engineering", "systembot"}, - }, - }, - } { - t.Run(name, func(t *testing.T) { - e := &ExplicitMentions{} - e.processText(tc.Text, tc.Keywords, tc.Groups) - - assert.EqualValues(t, tc.Expected, e) - }) - } -} - func TestGetNotificationNameFormat(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() @@ -2585,7 +2159,7 @@ func TestInsertGroupMentions(t *testing.T) { 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{} + mentions := &MentionResults{} usersMentioned, err := th.App.insertGroupMentions(group, channel, profileMap, mentions) require.Nil(t, err) require.Equal(t, usersMentioned, true) @@ -2601,7 +2175,7 @@ func TestInsertGroupMentions(t *testing.T) { }) t.Run("should add no expected or potential mentions if the group has no users ", func(t *testing.T) { - mentions := &ExplicitMentions{} + mentions := &MentionResults{} usersMentioned, err := th.App.insertGroupMentions(groupWithNoMembers, channel, profileMap, mentions) require.Nil(t, err) require.Equal(t, usersMentioned, false) @@ -2612,7 +2186,7 @@ func TestInsertGroupMentions(t *testing.T) { }) t.Run("should keep existing mentions", func(t *testing.T) { - mentions := &ExplicitMentions{} + mentions := &MentionResults{} th.App.insertGroupMentions(group, channel, profileMap, mentions) th.App.insertGroupMentions(groupWithNoMembers, channel, profileMap, mentions) @@ -2622,7 +2196,7 @@ func TestInsertGroupMentions(t *testing.T) { }) t.Run("should return true if no members mentioned while in group or direct message channel", func(t *testing.T) { - mentions := &ExplicitMentions{} + mentions := &MentionResults{} emptyProfileMap := make(map[string]*model.User) groupChannel := &model.Channel{Type: model.ChannelTypeGroup} @@ -2642,7 +2216,7 @@ func TestInsertGroupMentions(t *testing.T) { groupChannel, err := th.App.CreateGroupChannel(th.Context, []string{groupChannelMember.Id, nonGroupChannelMember.Id, th.BasicUser.Id}, groupChannelMember.Id) require.Nil(t, err) - mentions := &ExplicitMentions{} + mentions := &MentionResults{} th.App.insertGroupMentions(group, groupChannel, profileMap, mentions) require.Equal(t, len(mentions.Mentions), 1) @@ -2676,8 +2250,8 @@ func TestGetGroupsAllowedForReferenceInChannel(t *testing.T) { groupsMap, nErr := th.App.getGroupsAllowedForReferenceInChannel(channel, team) require.NoError(t, nErr) require.Len(t, groupsMap, 1) - require.Nil(t, groupsMap[*group2.Name]) - require.Equal(t, groupsMap[*group1.Name], group1) + require.Nil(t, groupsMap[group2.Id]) + require.Equal(t, groupsMap[group1.Id], group1) }) group2.AllowReference = true @@ -2700,8 +2274,8 @@ func TestGetGroupsAllowedForReferenceInChannel(t *testing.T) { groupsMap, nErr := th.App.getGroupsAllowedForReferenceInChannel(constrainedChannel, team) require.NoError(t, nErr) require.Len(t, groupsMap, 1) - require.Nil(t, groupsMap[*group2.Name]) - require.Equal(t, groupsMap[*group1.Name], group1) + require.Nil(t, groupsMap[group2.Id]) + require.Equal(t, groupsMap[group1.Id], group1) }) // Create a third group not synced with a team or channel @@ -2725,18 +2299,18 @@ func TestGetGroupsAllowedForReferenceInChannel(t *testing.T) { groupsMap, nErr := th.App.getGroupsAllowedForReferenceInChannel(channel, team) require.NoError(t, nErr) require.Len(t, groupsMap, 2) - require.Nil(t, groupsMap[*group3.Name]) - require.Equal(t, groupsMap[*group2.Name], group2) - require.Equal(t, groupsMap[*group1.Name], group1) + require.Nil(t, groupsMap[group3.Id]) + require.Equal(t, groupsMap[group2.Id], group2) + require.Equal(t, groupsMap[group1.Id], group1) }) t.Run("should return only subset of groups synced to channel for group constrained channel when team is also group constrained", func(t *testing.T) { groupsMap, nErr := th.App.getGroupsAllowedForReferenceInChannel(constrainedChannel, team) require.NoError(t, nErr) require.Len(t, groupsMap, 1) - require.Nil(t, groupsMap[*group3.Name]) - require.Nil(t, groupsMap[*group2.Name]) - require.Equal(t, groupsMap[*group1.Name], group1) + require.Nil(t, groupsMap[group3.Id]) + require.Nil(t, groupsMap[group2.Id]) + require.Equal(t, groupsMap[group1.Id], group1) }) team.GroupConstrained = model.NewBool(false) @@ -2747,9 +2321,9 @@ func TestGetGroupsAllowedForReferenceInChannel(t *testing.T) { groupsMap, nErr := th.App.getGroupsAllowedForReferenceInChannel(channel, team) require.NoError(t, nErr) require.Len(t, groupsMap, 3) - require.Equal(t, groupsMap[*group1.Name], group1) - require.Equal(t, groupsMap[*group2.Name], group2) - require.Equal(t, groupsMap[*group3.Name], group3) + require.Equal(t, groupsMap[group1.Id], group1) + require.Equal(t, groupsMap[group2.Id], group2) + require.Equal(t, groupsMap[group3.Id], group3) }) } diff --git a/server/channels/app/post.go b/server/channels/app/post.go index 98179c14e0..85eaa32064 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -199,7 +199,7 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel // Validate recipients counts in case it's not DM if persistentNotification := post.GetPersistentNotification(); persistentNotification != nil && *persistentNotification && channel.Type != model.ChannelTypeDirect { - err := a.forEachPersistentNotificationPost([]*model.Post{post}, func(_ *model.Post, _ *model.Channel, _ *model.Team, mentions *ExplicitMentions, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + err := a.forEachPersistentNotificationPost([]*model.Post{post}, func(_ *model.Post, _ *model.Channel, _ *model.Team, mentions *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { if maxRecipients := *a.Config().ServiceSettings.PersistentNotificationMaxRecipients; len(mentions.Mentions) > maxRecipients { return model.NewAppError("CreatePost", "api.post.post_priority.max_recipients_persistent_notification_post.request_error", map[string]any{"MaxRecipients": maxRecipients}, "", http.StatusBadRequest) } else if len(mentions.Mentions) == 0 { @@ -1780,8 +1780,8 @@ func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.P return 0, err } - keywords := addMentionKeywordsForUser( - map[string][]string{}, + keywords := MentionKeywords{} + keywords.AddUser( user, map[string]string{}, &model.Status{Status: model.StatusOnline}, // Assume the user is online since they would've triggered this @@ -1820,9 +1820,11 @@ func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.P return 0, model.NewAppError("countThreadMentions", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } + keywords.AddGroupsMap(groups) + for _, p := range posts { if p.CreateAt >= timestamp { - mentions := getExplicitMentions(p, keywords, groups) + mentions := getExplicitMentions(p, keywords) if _, ok := mentions.Mentions[user.Id]; ok { count += 1 } @@ -1863,8 +1865,8 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model return 0, 0, 0, err } - keywords := addMentionKeywordsForUser( - map[string][]string{}, + keywords := MentionKeywords{} + keywords.AddUser( user, channelMember.NotifyProps, &model.Status{Status: model.StatusOnline}, // Assume the user is online since they would've triggered this @@ -1989,14 +1991,14 @@ func isCommentMention(user *model.User, post *model.Post, otherPosts map[string] return mentioned } -func isPostMention(user *model.User, post *model.Post, keywords map[string][]string, otherPosts map[string]*model.Post, mentionedByThread map[string]bool, checkForCommentMentions bool) bool { +func isPostMention(user *model.User, post *model.Post, keywords MentionKeywords, otherPosts map[string]*model.Post, mentionedByThread map[string]bool, checkForCommentMentions bool) bool { // Prevent the user from mentioning themselves if post.UserId == user.Id && post.GetProp("from_webhook") != "true" { return false } // Check for keyword mentions - mentions := getExplicitMentions(post, keywords, make(map[string]*model.Group)) + mentions := getExplicitMentions(post, keywords) if _, ok := mentions.Mentions[user.Id]; ok { return true } diff --git a/server/channels/app/post_persistent_notification.go b/server/channels/app/post_persistent_notification.go index a8ff30f1fe..7325f86931 100644 --- a/server/channels/app/post_persistent_notification.go +++ b/server/channels/app/post_persistent_notification.go @@ -56,7 +56,7 @@ func (a *App) ResolvePersistentNotification(c request.CTX, post *model.Post, log } stopNotifications := false - if err := a.forEachPersistentNotificationPost([]*model.Post{post}, func(_ *model.Post, _ *model.Channel, _ *model.Team, mentions *ExplicitMentions, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + if err := a.forEachPersistentNotificationPost([]*model.Post{post}, func(_ *model.Post, _ *model.Channel, _ *model.Team, mentions *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { if mentions.isUserMentioned(loggedInUserID) { stopNotifications = true } @@ -151,7 +151,7 @@ func (a *App) SendPersistentNotifications() error { return nil } -func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(post *model.Post, channel *model.Channel, team *model.Team, mentions *ExplicitMentions, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error) error { +func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(post *model.Post, channel *model.Channel, team *model.Team, mentions *MentionResults, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error) error { channelsMap, teamsMap, err := a.channelTeamMapsForPosts(posts) if err != nil { return err @@ -171,7 +171,7 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos } profileMap := channelProfileMap[channel.Id] - mentions := &ExplicitMentions{} + mentions := &MentionResults{} // In DMs, only the "other" user can be mentioned if channel.Type == model.ChannelTypeDirect { otherUserId := channel.GetOtherUserIdForDM(post.UserId) @@ -180,8 +180,11 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos } } else { keywords := channelKeywords[channel.Id] - mentions = getExplicitMentions(post, keywords, channelGroupMap[channel.Id]) - for _, group := range mentions.GroupMentions { + keywords.AddGroupsMap(channelGroupMap[channel.Id]) + + mentions = getExplicitMentions(post, keywords) + for groupID := range mentions.GroupMentions { + group := channelGroupMap[channel.Id][groupID] _, err := a.insertGroupMentions(group, channel, profileMap, mentions) if err != nil { return errors.Wrapf(err, "failed to include mentions from group - %s for channel - %s", group.Id, channel.Id) @@ -197,10 +200,10 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos return nil } -func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model.Channel, teamsMap map[string]*model.Team) (map[string]map[string]*model.Group, map[string]model.UserMap, map[string]map[string][]string, map[string]map[string]model.StringMap, error) { +func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model.Channel, teamsMap map[string]*model.Team) (map[string]map[string]*model.Group, map[string]model.UserMap, map[string]MentionKeywords, map[string]map[string]model.StringMap, error) { channelGroupMap := make(map[string]map[string]*model.Group, len(channelsMap)) channelProfileMap := make(map[string]model.UserMap, len(channelsMap)) - channelKeywords := make(map[string]map[string][]string, len(channelsMap)) + channelKeywords := make(map[string]MentionKeywords, len(channelsMap)) channelNotifyProps := make(map[string]map[string]model.StringMap, len(channelsMap)) for _, c := range channelsMap { // In DM, notifications can't be send to any 3rd person. @@ -210,8 +213,8 @@ func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id) } channelGroupMap[c.Id] = make(map[string]*model.Group, len(groups)) - for k, v := range groups { - channelGroupMap[c.Id][k] = v + for groupID, group := range groups { + channelGroupMap[c.Id][groupID] = group } props, err := a.Srv().Store().Channel().GetAllChannelMembersNotifyPropsForChannel(c.Id, true) if err != nil { @@ -225,14 +228,14 @@ func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id) } - channelKeywords[c.Id] = make(map[string][]string, len(profileMap)) + channelKeywords[c.Id] = make(MentionKeywords, len(profileMap)) validProfileMap := make(map[string]*model.User, len(profileMap)) - for k, v := range profileMap { - if v.IsBot { + for userID, user := range profileMap { + if user.IsBot { continue } - validProfileMap[k] = v - channelKeywords[c.Id]["@"+v.Username] = []string{k} + validProfileMap[userID] = user + channelKeywords[c.Id].AddUserKeyword(userID, "@"+user.Username) } channelProfileMap[c.Id] = validProfileMap } @@ -273,7 +276,7 @@ func (a *App) channelTeamMapsForPosts(posts []*model.Post) (map[string]*model.Ch return channelsMap, teamsMap, nil } -func (a *App) sendPersistentNotifications(post *model.Post, channel *model.Channel, team *model.Team, mentions *ExplicitMentions, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error { +func (a *App) sendPersistentNotifications(post *model.Post, channel *model.Channel, team *model.Team, mentions *MentionResults, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error { mentionedUsersList := make(model.StringArray, 0, len(mentions.Mentions)) for id, v := range mentions.Mentions { // Don't send notification to post owner nor GM mentions