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 <jesse.hallam@gmail.com>

* Address feedback

---------

Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>

---------

Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>
This commit is contained in:
Harrison Healey 2023-10-23 12:37:58 -04:00 committed by GitHub
parent 74f35aa92c
commit a78710c2a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1659 additions and 1182 deletions

View File

@ -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
}

View File

@ -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))
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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())
})
}
}

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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