From 3dad6320432c1cbeb032b57619ba78f29433dce3 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 23 Jan 2018 13:48:20 -0600 Subject: use markdown parsing to identify mentions (#8139) --- app/notification.go | 206 +++++++++++++++++++++++++++------------------------- 1 file changed, 107 insertions(+), 99 deletions(-) (limited to 'app/notification.go') diff --git a/app/notification.go b/app/notification.go index 62aad4c28..7223fb3aa 100644 --- a/app/notification.go +++ b/app/notification.go @@ -10,7 +10,6 @@ import ( "net/http" "net/url" "path/filepath" - "regexp" "sort" "strings" "time" @@ -20,6 +19,7 @@ import ( "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/store" "github.com/mattermost/mattermost-server/utils" + "github.com/mattermost/mattermost-server/utils/markdown" "github.com/nicksnyder/go-i18n/i18n" ) @@ -71,8 +71,8 @@ func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *mod } else { keywords := a.GetMentionKeywordsInChannel(profileMap, post.Type != model.POST_HEADER_CHANGE && post.Type != model.POST_PURPOSE_CHANGE) - var potentialOtherMentions []string - mentionedUserIds, potentialOtherMentions, hereNotification, channelNotification, allNotification = GetExplicitMentions(post.Message, keywords) + m := GetExplicitMentions(post.Message, keywords) + mentionedUserIds, hereNotification, channelNotification, allNotification = m.MentionedUserIds, m.HereMentioned, m.ChannelMentioned, m.AllMentioned // get users that have comment thread mentions enabled if len(post.RootId) > 0 && parentPostList != nil { @@ -89,8 +89,8 @@ func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *mod delete(mentionedUserIds, post.UserId) } - if len(potentialOtherMentions) > 0 { - if result := <-a.Srv.Store.User().GetProfilesByUsernames(potentialOtherMentions, team.Id); result.Err == nil { + if len(m.OtherPotentialMentions) > 0 { + if result := <-a.Srv.Store.User().GetProfilesByUsernames(m.OtherPotentialMentions, team.Id); result.Err == nil { outOfChannelMentions := result.Data.([]*model.User) if channel.Type != model.CHANNEL_GROUP { a.Go(func() { @@ -788,125 +788,133 @@ func (a *App) sendOutOfChannelMentions(sender *model.User, post *model.Post, cha return nil } +type ExplicitMentions struct { + // MentionedUserIds contains a key for each user mentioned by keyword. + MentionedUserIds map[string]bool + + // 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 +} + // 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(message string, keywords map[string][]string) (map[string]bool, []string, bool, bool, bool) { - mentioned := make(map[string]bool) - potentialOthersMentioned := make([]string, 0) +func GetExplicitMentions(message string, keywords map[string][]string) *ExplicitMentions { + ret := &ExplicitMentions{ + MentionedUserIds: make(map[string]bool), + } systemMentions := map[string]bool{"@here": true, "@channel": true, "@all": true} - hereMentioned := false - allMentioned := false - channelMentioned := false addMentionedUsers := func(ids []string) { for _, id := range ids { - mentioned[id] = true + ret.MentionedUserIds[id] = true } } - message = removeCodeFromMessage(message) - - for _, word := range strings.FieldsFunc(message, 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)) - }) { - isMention := false - - // skip word with format ':word:' with an assumption that it is an emoji format only - if word[0] == ':' && word[len(word)-1] == ':' { - continue - } - - if word == "@here" { - hereMentioned = true - } + processText := func(text string) { + 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)) + }) { + isMention := false - if word == "@channel" { - channelMentioned = true - } - - if word == "@all" { - allMentioned = true - } - - // Non-case-sensitive check for regular keys - if ids, match := keywords[strings.ToLower(word)]; match { - addMentionedUsers(ids) - isMention = true - } + // skip word with format ':word:' with an assumption that it is an emoji format only + if word[0] == ':' && word[len(word)-1] == ':' { + continue + } - // Case-sensitive check for first name - if ids, match := keywords[word]; match { - addMentionedUsers(ids) - isMention = true - } + if word == "@here" { + ret.HereMentioned = true + } - if isMention { - continue - } + if word == "@channel" { + ret.ChannelMentioned = true + } - 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 == ':' - }) + if word == "@all" { + ret.AllMentioned = true + } - for _, splitWord := range splitWords { - if splitWord == "@here" { - hereMentioned = true - } + // Non-case-sensitive check for regular keys + if ids, match := keywords[strings.ToLower(word)]; match { + addMentionedUsers(ids) + isMention = true + } - if splitWord == "@all" { - allMentioned = true - } + // Case-sensitive check for first name + if ids, match := keywords[word]; match { + addMentionedUsers(ids) + isMention = true + } - if splitWord == "@channel" { - channelMentioned = true - } + if isMention { + continue + } - // Non-case-sensitive check for regular keys - if ids, match := keywords[strings.ToLower(splitWord)]; match { - addMentionedUsers(ids) - } + 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 == ':' + }) - // Case-sensitive check for first name - if ids, match := keywords[splitWord]; match { - addMentionedUsers(ids) - } else if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") { - username := splitWord[1:] - potentialOthersMentioned = append(potentialOthersMentioned, username) + for _, splitWord := range splitWords { + if splitWord == "@here" { + ret.HereMentioned = true + } + + if splitWord == "@all" { + ret.AllMentioned = true + } + + if splitWord == "@channel" { + ret.ChannelMentioned = true + } + + // Non-case-sensitive check for regular keys + if ids, match := keywords[strings.ToLower(splitWord)]; match { + addMentionedUsers(ids) + } + + // Case-sensitive check for first name + if ids, match := keywords[splitWord]; match { + addMentionedUsers(ids) + } else if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") { + username := splitWord[1:] + ret.OtherPotentialMentions = append(ret.OtherPotentialMentions, username) + } } } - } - if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { - username := word[1:] - potentialOthersMentioned = append(potentialOthersMentioned, username) + if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { + username := word[1:] + ret.OtherPotentialMentions = append(ret.OtherPotentialMentions, username) + } } } - return mentioned, potentialOthersMentioned, hereMentioned, channelMentioned, allMentioned -} - -// Matches a line containing only ``` and a potential language definition, any number of lines not containing ```, -// and then either a line containing only ``` or the end of the text -var codeBlockPattern = regexp.MustCompile("(?m)^[^\\S\n]*[\\`~]{3}.*$[\\s\\S]+?(^[^\\S\n]*[`~]{3}$|\\z)") - -// Matches a backquote, either some text or any number of non-empty lines, and then a final backquote -var inlineCodePattern = regexp.MustCompile("(?m)\\`+(?:.+?|.*?\n(.*?\\S.*?\n)*.*?)\\`+") - -// Strips pre-formatted text and code blocks from a Markdown string by replacing them with whitespace -func removeCodeFromMessage(message string) string { - if strings.Contains(message, "```") || strings.Contains(message, "~~~") { - message = codeBlockPattern.ReplaceAllString(message, "") - } - - // Replace with a space to prevent cases like "user`code`name" from turning into "username" - if strings.Contains(message, "`") { - message = inlineCodePattern.ReplaceAllString(message, " ") - } + buf := "" + markdown.Inspect(message, func(node interface{}) bool { + text, ok := node.(*markdown.Text) + if !ok { + processText(buf) + buf = "" + return true + } + buf += text.Text + return false + }) + processText(buf) - return message + return ret } // Given a map of user IDs to profiles, returns a list of mention -- cgit v1.2.3-1-g7c22