Files
mostlymatter/app/notification.go
Kyriakos Z b15cd9d29f MM-41565,MM-42473: fixes incorrect unread thread count (#19962)
* MM-41565,MM-42473: fixes incorrect unread thread count

The infamous "-1 unread threads" bug, was due to incorrectly counting
previous unread mentions and replies.

When we calculate the previous counts, we didn't take into account if
a thread was newly followed one or not. So when a user gets added to a
thread via a mention receives a WS event with the previous count values
calculated by subtracting "1" from the current count values.
In the result the webapp subtracts the previous values for the thread
from the total values of all threads for the user.

This is wrong since the user just followed the thread those previous values
should be 0. Which results into showing a negative value of unread
threads to the user.

This commit fixes this issue by zeroing the previous count values when
the user was not following the thread previously.

* Adds test, and addresses comments

* Changes test's description

* Removes unneeded assertion

* Empty commit just to re-build
2022-04-15 12:47:04 -04:00

1466 строки
47 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"encoding/json"
"net/http"
"sort"
"strings"
"sync"
"unicode"
"unicode/utf8"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/i18n"
"github.com/mattermost/mattermost-server/v6/shared/markdown"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/mattermost-server/v6/store"
)
func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error) {
// Do not send notifications in archived channels
if channel.DeleteAt > 0 {
return []string{}, nil
}
isCRTAllowed := a.Config().FeatureFlags.CollapsedThreads && *a.Config().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled
pchan := make(chan store.StoreResult, 1)
go func() {
props, err := a.Srv().Store.User().GetAllProfilesInChannel(context.Background(), channel.Id, true)
pchan <- store.StoreResult{Data: props, NErr: err}
close(pchan)
}()
cmnchan := make(chan store.StoreResult, 1)
go func() {
props, err := a.Srv().Store.Channel().GetAllChannelMembersNotifyPropsForChannel(channel.Id, true)
cmnchan <- store.StoreResult{Data: props, NErr: err}
close(cmnchan)
}()
var gchan chan store.StoreResult
if a.allowGroupMentions(post) {
gchan = make(chan store.StoreResult, 1)
go func() {
groupsMap, err := a.getGroupsAllowedForReferenceInChannel(channel, team)
gchan <- store.StoreResult{Data: groupsMap, NErr: err}
close(gchan)
}()
}
var fchan chan store.StoreResult
if len(post.FileIds) != 0 {
fchan = make(chan store.StoreResult, 1)
go func() {
fileInfos, err := a.Srv().Store.FileInfo().GetForPost(post.Id, true, false, true)
fchan <- store.StoreResult{Data: fileInfos, NErr: err}
close(fchan)
}()
}
var tchan chan store.StoreResult
if isCRTAllowed && post.RootId != "" {
tchan = make(chan store.StoreResult, 1)
go func() {
followers, err := a.Srv().Store.Thread().GetThreadFollowers(post.RootId, true)
tchan <- store.StoreResult{Data: followers, NErr: err}
close(tchan)
}()
}
result := <-pchan
if result.NErr != nil {
return nil, result.NErr
}
profileMap := result.Data.(map[string]*model.User)
result = <-cmnchan
if result.NErr != nil {
return nil, result.NErr
}
channelMemberNotifyPropsMap := result.Data.(map[string]model.StringMap)
followers := make(model.StringArray, 0)
if tchan != nil {
result = <-tchan
if result.NErr != nil {
return nil, result.NErr
}
followers = result.Data.([]string)
}
groups := make(map[string]*model.Group)
if gchan != nil {
result = <-gchan
if result.NErr != nil {
return nil, result.NErr
}
groups = result.Data.(map[string]*model.Group)
}
mentions := &ExplicitMentions{}
allActivityPushUserIds := []string{}
var allowChannelMentions bool
var keywords map[string][]string
if channel.Type == model.ChannelTypeDirect {
otherUserId := channel.GetOtherUserIdForDM(post.UserId)
_, ok := profileMap[otherUserId]
if ok {
mentions.addMention(otherUserId, DMMention)
}
if post.GetProp("from_webhook") == "true" {
mentions.addMention(post.UserId, DMMention)
}
} else {
allowChannelMentions = a.allowChannelMentions(post, len(profileMap))
keywords = a.getMentionKeywordsInChannel(profileMap, allowChannelMentions, channelMemberNotifyPropsMap)
mentions = getExplicitMentions(post, keywords, groups)
// Add an implicit mention when a user is added to a channel
// even if the user has set 'username mentions' to false in account settings.
if post.Type == model.PostTypeAddToChannel {
addedUserId, ok := post.GetProp(model.PostPropsAddedUserId).(string)
if ok {
mentions.addMention(addedUserId, KeywordMention)
}
}
// Iterate through all groups that were mentioned and insert group members into the list of mentions or potential mentions
for _, group := range mentions.GroupMentions {
anyUsersMentionedByGroup, err := a.insertGroupMentions(group, channel, profileMap, mentions)
if err != nil {
return nil, err
}
if !anyUsersMentionedByGroup {
a.sendNoUsersNotifiedByGroupInChannel(sender, post, channel, group)
}
}
// get users that have comment thread mentions enabled
if post.RootId != "" && parentPostList != nil {
for _, threadPost := range parentPostList.Posts {
profile := profileMap[threadPost.UserId]
if profile == nil {
continue
}
// If this is the root post and it was posted by an OAuth bot, don't notify the user
if threadPost.Id == parentPostList.Order[0] && threadPost.IsFromOAuthBot() {
continue
}
if a.IsCRTEnabledForUser(profile.Id) {
continue
}
if profile.NotifyProps[model.CommentsNotifyProp] == model.CommentsNotifyAny || (profile.NotifyProps[model.CommentsNotifyProp] == model.CommentsNotifyRoot && threadPost.Id == parentPostList.Order[0]) {
mentionType := ThreadMention
if threadPost.Id == parentPostList.Order[0] {
mentionType = CommentMention
}
mentions.addMention(threadPost.UserId, mentionType)
}
}
}
// prevent the user from mentioning themselves
if post.GetProp("from_webhook") != "true" {
mentions.removeMention(post.UserId)
}
go func() {
_, err := a.sendOutOfChannelMentions(sender, post, channel, mentions.OtherPotentialMentions)
if err != nil {
mlog.Error("Failed to send warning for out of channel mentions", mlog.String("user_id", sender.Id), mlog.String("post_id", post.Id), mlog.Err(err))
}
}()
// find which users in the channel are set up to always receive mobile notifications
// excludes CRT users since those should be added in notificationsForCRT
for _, profile := range profileMap {
if (profile.NotifyProps[model.PushNotifyProp] == model.UserNotifyAll ||
channelMemberNotifyPropsMap[profile.Id][model.PushNotifyProp] == model.ChannelNotifyAll) &&
(post.UserId != profile.Id || post.GetProp("from_webhook") == "true") &&
!post.IsSystemMessage() &&
!(a.IsCRTEnabledForUser(profile.Id) && post.RootId != "") {
allActivityPushUserIds = append(allActivityPushUserIds, profile.Id)
}
}
}
mentionedUsersList := make(model.StringArray, 0, len(mentions.Mentions))
mentionAutofollowChans := []chan *model.AppError{}
threadParticipants := map[string]bool{post.UserId: true}
newParticipants := map[string]bool{}
participantMemberships := map[string]*model.ThreadMembership{}
membershipsMutex := &sync.Mutex{}
followersMutex := &sync.Mutex{}
if *a.Config().ServiceSettings.ThreadAutoFollow && post.RootId != "" {
var rootMentions *ExplicitMentions
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)
for id := range rootMentions.Mentions {
threadParticipants[id] = true
}
}
}
for id := range mentions.Mentions {
threadParticipants[id] = true
}
// sema is a counting semaphore to throttle the number of concurrent DB requests.
// A concurrency of 8 should be sufficient.
// We don't want to set a higher limit which can bring down the DB.
sema := make(chan struct{}, 8)
// for each mention, make sure to update thread autofollow (if enabled) and update increment mention count
for id := range threadParticipants {
mac := make(chan *model.AppError, 1)
// Get token.
sema <- struct{}{}
go func(userID string) {
defer func() {
close(mac)
// Release token.
<-sema
}()
mentionType, incrementMentions := mentions.Mentions[userID]
// if the user was not explicitly mentioned, check if they explicitly unfollowed the thread
if !incrementMentions {
membership, err := a.Srv().Store.Thread().GetMembershipForUser(userID, post.RootId)
var nfErr *store.ErrNotFound
if err != nil && !errors.As(err, &nfErr) {
mac <- model.NewAppError("SendNotifications", "app.channel.autofollow.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
if membership != nil && !membership.Following {
return
}
}
updateFollowing := *a.Config().ServiceSettings.ThreadAutoFollow
if mentionType == ThreadMention || mentionType == CommentMention {
incrementMentions = false
updateFollowing = false
}
opts := store.ThreadMembershipOpts{
Following: true,
IncrementMentions: incrementMentions,
UpdateFollowing: updateFollowing,
UpdateViewedTimestamp: false,
UpdateParticipants: userID == post.UserId,
}
threadMembership, err := a.Srv().Store.Thread().MaintainMembership(userID, post.RootId, opts)
if err != nil {
mac <- model.NewAppError("SendNotifications", "app.channel.autofollow.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
followersMutex.Lock()
// add new followers to existing followers
if threadMembership.Following && !followers.Contains(userID) {
followers = append(followers, userID)
newParticipants[userID] = true
}
followersMutex.Unlock()
membershipsMutex.Lock()
participantMemberships[userID] = threadMembership
membershipsMutex.Unlock()
mac <- nil
}(id)
mentionAutofollowChans = append(mentionAutofollowChans, mac)
}
}
for id := range mentions.Mentions {
mentionedUsersList = append(mentionedUsersList, id)
}
nErr := a.Srv().Store.Channel().IncrementMentionCount(post.ChannelId, mentionedUsersList, post.RootId == "")
if nErr != nil {
mlog.Warn(
"Failed to update mention count",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.Err(nErr),
)
}
// Log the problems that might have occurred while auto following the thread
for _, mac := range mentionAutofollowChans {
if err := <-mac; err != nil {
mlog.Warn(
"Failed to update thread autofollow from mention",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.Err(err),
)
}
}
notificationsForCRT := &CRTNotifiers{}
if isCRTAllowed && post.RootId != "" {
for _, uid := range followers {
profile := profileMap[uid]
if profile == nil || !a.IsCRTEnabledForUser(uid) {
continue
}
if post.GetProp("from_webhook") != "true" && uid == post.UserId {
continue
}
// add user id to notificationsForCRT depending on threads notify props
notificationsForCRT.addFollowerToNotify(profile, mentions, channelMemberNotifyPropsMap[profile.Id], channel)
}
}
notification := &PostNotification{
Post: post.Clone(),
Channel: channel,
ProfileMap: profileMap,
Sender: sender,
}
if *a.Config().EmailSettings.SendEmailNotifications {
emailRecipients := append(mentionedUsersList, notificationsForCRT.Email...)
emailRecipients = model.RemoveDuplicateStrings(emailRecipients)
for _, id := range emailRecipients {
if profileMap[id] == nil {
continue
}
//If email verification is required and user email is not verified don't send email.
if *a.Config().EmailSettings.RequireEmailVerification && !profileMap[id].EmailVerified {
mlog.Debug("Skipped sending notification email, address not verified.", mlog.String("user_email", profileMap[id].Email), mlog.String("user_id", id))
continue
}
if a.userAllowsEmail(profileMap[id], channelMemberNotifyPropsMap[id], post) {
senderProfileImage, _, err := a.GetProfileImage(sender)
if err != nil {
a.Log().Warn("Unable to get the sender user profile image.", mlog.String("user_id", sender.Id), mlog.Err(err))
}
if err := a.sendNotificationEmail(notification, profileMap[id], team, senderProfileImage); err != nil {
mlog.Warn("Unable to send notification email.", mlog.Err(err))
}
}
}
}
// Check for channel-wide mentions in channels that have too many members for those to work
if int64(len(profileMap)) > *a.Config().TeamSettings.MaxNotificationsPerChannel {
T := i18n.GetUserTranslations(sender.Locale)
if mentions.HereMentioned {
a.SendEphemeralPost(
post.UserId,
&model.Post{
ChannelId: post.ChannelId,
Message: T("api.post.disabled_here", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
CreateAt: post.CreateAt + 1,
},
)
}
if mentions.ChannelMentioned {
a.SendEphemeralPost(
post.UserId,
&model.Post{
ChannelId: post.ChannelId,
Message: T("api.post.disabled_channel", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
CreateAt: post.CreateAt + 1,
},
)
}
if mentions.AllMentioned {
a.SendEphemeralPost(
post.UserId,
&model.Post{
ChannelId: post.ChannelId,
Message: T("api.post.disabled_all", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
CreateAt: post.CreateAt + 1,
},
)
}
}
sendPushNotifications := false
if *a.Config().EmailSettings.SendPushNotifications {
pushServer := *a.Config().EmailSettings.PushNotificationServer
if license := a.Srv().License(); pushServer == model.MHPNS && (license == nil || !*license.Features.MHPNS) {
mlog.Warn("Push notifications are disabled. Go to System Console > Notifications > Mobile Push to enable them.")
sendPushNotifications = false
} else {
sendPushNotifications = true
}
}
if sendPushNotifications {
for _, id := range mentionedUsersList {
if profileMap[id] == nil || notificationsForCRT.Push.Contains(id) {
continue
}
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(id); err != nil {
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], true, status, post) {
mentionType := mentions.Mentions[id]
replyToThreadType := ""
if mentionType == ThreadMention {
replyToThreadType = model.CommentsNotifyAny
} else if mentionType == CommentMention {
replyToThreadType = model.CommentsNotifyRoot
}
a.sendPushNotification(
notification,
profileMap[id],
mentionType == KeywordMention || mentionType == ChannelMention || mentionType == DMMention,
mentionType == ChannelMention,
replyToThreadType,
)
} else {
// register that a notification was not sent
a.NotificationsLog().Debug("Notification not sent",
mlog.String("ackId", ""),
mlog.String("type", model.PushTypeMessage),
mlog.String("userId", id),
mlog.String("postId", post.Id),
mlog.String("status", model.PushNotSent),
)
}
}
for _, id := range allActivityPushUserIds {
if profileMap[id] == nil || notificationsForCRT.Push.Contains(id) {
continue
}
if _, ok := mentions.Mentions[id]; !ok {
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(id); err != nil {
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], false, status, post) {
a.sendPushNotification(
notification,
profileMap[id],
false,
false,
"",
)
} else {
// register that a notification was not sent
a.NotificationsLog().Debug("Notification not sent",
mlog.String("ackId", ""),
mlog.String("type", model.PushTypeMessage),
mlog.String("userId", id),
mlog.String("postId", post.Id),
mlog.String("status", model.PushNotSent),
)
}
}
}
for _, id := range notificationsForCRT.Push {
if profileMap[id] == nil {
continue
}
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(id); err != nil {
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
if DoesStatusAllowPushNotification(profileMap[id].NotifyProps, status, post.ChannelId) {
a.sendPushNotification(
notification,
profileMap[id],
false,
false,
model.CommentsNotifyCRT,
)
} else {
// register that a notification was not sent
a.NotificationsLog().Debug("Notification not sent",
mlog.String("ackId", ""),
mlog.String("type", model.PushTypeMessage),
mlog.String("userId", id),
mlog.String("postId", post.Id),
mlog.String("status", model.PushNotSent),
)
}
}
}
message := model.NewWebSocketEvent(model.WebsocketEventPosted, "", post.ChannelId, "", nil)
// Note that PreparePostForClient should've already been called by this point
postJSON, jsonErr := post.ToJSON()
if jsonErr != nil {
return nil, errors.Wrapf(jsonErr, "failed to encode post to JSON")
}
message.Add("post", postJSON)
message.Add("channel_type", channel.Type)
message.Add("channel_display_name", notification.GetChannelName(model.ShowUsername, ""))
message.Add("channel_name", channel.Name)
message.Add("sender_name", notification.GetSenderName(model.ShowUsername, *a.Config().ServiceSettings.EnablePostUsernameOverride))
message.Add("team_id", team.Id)
message.Add("set_online", setOnline)
if len(post.FileIds) != 0 && fchan != nil {
message.Add("otherFile", "true")
var infos []*model.FileInfo
if result := <-fchan; result.NErr != nil {
mlog.Warn("Unable to get fileInfo for push notifications.", mlog.String("post_id", post.Id), mlog.Err(result.NErr))
} else {
infos = result.Data.([]*model.FileInfo)
}
for _, info := range infos {
if info.IsImage() {
message.Add("image", "true")
break
}
}
}
if len(mentionedUsersList) != 0 {
message.Add("mentions", model.ArrayToJSON(mentionedUsersList))
}
if len(notificationsForCRT.Desktop) != 0 {
message.Add("followers", model.ArrayToJSON(notificationsForCRT.Desktop))
}
published, err := a.publishWebsocketEventForPermalinkPost(post, message)
if err != nil {
return nil, err
}
if !published {
a.Publish(message)
}
// If this is a reply in a thread, notify participants
if isCRTAllowed && post.RootId != "" {
for _, uid := range followers {
// A user following a thread but had left the channel won't get a notification
// https://mattermost.atlassian.net/browse/MM-36769
if profileMap[uid] == nil {
continue
}
if a.IsCRTEnabledForUser(uid) {
message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, team.Id, "", uid, nil)
threadMembership := participantMemberships[uid]
if threadMembership == nil {
tm, err := a.Srv().Store.Thread().GetMembershipForUser(uid, post.RootId)
if err != nil {
return nil, errors.Wrapf(err, "Missing thread membership for participant in notifications. user_id=%q thread_id=%q", uid, post.RootId)
}
if tm == nil {
continue
}
threadMembership = tm
}
userThread, err := a.Srv().Store.Thread().GetThreadForUser(channel.TeamId, threadMembership, true)
if err != nil {
return nil, errors.Wrapf(err, "cannot get thread %q for user %q", post.RootId, uid)
}
if userThread != nil {
previousUnreadMentions := int64(0)
previousUnreadReplies := int64(0)
// if it's not a newly followed thread, calculate previous unread values.
if !newParticipants[uid] {
previousUnreadMentions = userThread.UnreadMentions
previousUnreadReplies = max(userThread.UnreadReplies-1, 0)
if mentions.isUserMentioned(uid) {
previousUnreadMentions = max(userThread.UnreadMentions-1, 0)
}
}
// set LastViewed to now for commenter
if uid == post.UserId {
opts := store.ThreadMembershipOpts{
UpdateViewedTimestamp: true,
}
// should set unread mentions, and unread replies to 0
_, err = a.Srv().Store.Thread().MaintainMembership(uid, post.RootId, opts)
if err != nil {
return nil, errors.Wrapf(err, "cannot maintain thread membership %q for user %q", post.RootId, uid)
}
userThread.UnreadMentions = 0
userThread.UnreadReplies = 0
}
a.sanitizeProfiles(userThread.Participants, false)
userThread.Post.SanitizeProps()
sanitizedPost, err := a.SanitizePostMetadataForUser(userThread.Post, uid)
if err != nil {
return nil, err
}
userThread.Post = sanitizedPost
payload, jsonErr := json.Marshal(userThread)
if jsonErr != nil {
mlog.Warn("Failed to encode thread to JSON")
}
message.Add("thread", string(payload))
message.Add("previous_unread_mentions", previousUnreadMentions)
message.Add("previous_unread_replies", previousUnreadReplies)
a.Publish(message)
}
}
}
}
return mentionedUsersList, nil
}
func max(a, b int64) int64 {
if a < b {
return b
}
return a
}
func (a *App) userAllowsEmail(user *model.User, channelMemberNotificationProps model.StringMap, post *model.Post) bool {
// if user is a bot account, then we do not send email
if user.IsBot {
return false
}
userAllowsEmails := user.NotifyProps[model.EmailNotifyProp] != "false"
// if CRT is ON for user and the post is a reply disregard the channelEmail setting
if channelEmail, ok := channelMemberNotificationProps[model.EmailNotifyProp]; ok && !(a.IsCRTEnabledForUser(user.Id) && post.RootId != "") {
if channelEmail != model.ChannelNotifyDefault {
userAllowsEmails = channelEmail != "false"
}
}
// Remove the user as recipient when the user has muted the channel.
if channelMuted, ok := channelMemberNotificationProps[model.MarkUnreadNotifyProp]; ok {
if channelMuted == model.ChannelMarkUnreadMention {
mlog.Debug("Channel muted for user", mlog.String("user_id", user.Id), mlog.String("channel_mute", channelMuted))
userAllowsEmails = false
}
}
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(user.Id); err != nil {
status = &model.Status{
UserId: user.Id,
Status: model.StatusOffline,
Manual: false,
LastActivityAt: 0,
ActiveChannel: "",
}
}
autoResponderRelated := status.Status == model.StatusOutOfOffice || post.Type == model.PostTypeAutoResponder
emailNotificationsAllowedForStatus := status.Status != model.StatusOnline && status.Status != model.StatusDnd
return userAllowsEmails && emailNotificationsAllowedForStatus && user.DeleteAt == 0 && !autoResponderRelated
}
func (a *App) sendNoUsersNotifiedByGroupInChannel(sender *model.User, post *model.Post, channel *model.Channel, group *model.Group) {
T := i18n.GetUserTranslations(sender.Locale)
ephemeralPost := &model.Post{
UserId: sender.Id,
RootId: post.RootId,
ChannelId: channel.Id,
Message: T("api.post.check_for_out_of_channel_group_users.message.none", model.StringInterface{"GroupName": group.Name}),
}
a.SendEphemeralPost(post.UserId, ephemeralPost)
}
// sendOutOfChannelMentions sends an ephemeral post to the sender of a post if any of the given potential mentions
// are outside of the post's channel. Returns whether or not an ephemeral post was sent.
func (a *App) sendOutOfChannelMentions(sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) (bool, error) {
outOfChannelUsers, outOfGroupsUsers, err := a.filterOutOfChannelMentions(sender, post, channel, potentialMentions)
if err != nil {
return false, err
}
if len(outOfChannelUsers) == 0 && len(outOfGroupsUsers) == 0 {
return false, nil
}
a.SendEphemeralPost(post.UserId, makeOutOfChannelMentionPost(sender, post, outOfChannelUsers, outOfGroupsUsers))
return true, nil
}
func (a *App) FilterUsersByVisible(viewer *model.User, otherUsers []*model.User) ([]*model.User, *model.AppError) {
result := []*model.User{}
for _, user := range otherUsers {
canSee, err := a.UserCanSeeOtherUser(viewer.Id, user.Id)
if err != nil {
return nil, err
}
if canSee {
result = append(result, user)
}
}
return result, nil
}
func (a *App) filterOutOfChannelMentions(sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) ([]*model.User, []*model.User, error) {
if post.IsSystemMessage() {
return nil, nil, nil
}
if channel.TeamId == "" || channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
return nil, nil, nil
}
if len(potentialMentions) == 0 {
return nil, nil, nil
}
users, err := a.Srv().Store.User().GetProfilesByUsernames(potentialMentions, &model.ViewUsersRestrictions{Teams: []string{channel.TeamId}})
if err != nil {
return nil, nil, err
}
// Filter out inactive users and bots
allUsers := model.UserSlice(users).FilterByActive(true)
allUsers = allUsers.FilterWithoutBots()
allUsers, appErr := a.FilterUsersByVisible(sender, allUsers)
if appErr != nil {
return nil, nil, appErr
}
if len(allUsers) == 0 {
return nil, nil, nil
}
// Differentiate between users who can and can't be added to the channel
var outOfChannelUsers model.UserSlice
var outOfGroupsUsers model.UserSlice
if channel.IsGroupConstrained() {
nonMemberIDs, err := a.FilterNonGroupChannelMembers(allUsers.IDs(), channel)
if err != nil {
return nil, nil, err
}
outOfChannelUsers = allUsers.FilterWithoutID(nonMemberIDs)
outOfGroupsUsers = allUsers.FilterByID(nonMemberIDs)
} else {
outOfChannelUsers = allUsers
}
return outOfChannelUsers, outOfGroupsUsers, nil
}
func makeOutOfChannelMentionPost(sender *model.User, post *model.Post, outOfChannelUsers, outOfGroupsUsers []*model.User) *model.Post {
allUsers := model.UserSlice(append(outOfChannelUsers, outOfGroupsUsers...))
ocUsers := model.UserSlice(outOfChannelUsers)
ocUsernames := ocUsers.Usernames()
ocUserIDs := ocUsers.IDs()
ogUsers := model.UserSlice(outOfGroupsUsers)
ogUsernames := ogUsers.Usernames()
T := i18n.GetUserTranslations(sender.Locale)
ephemeralPostId := model.NewId()
var message string
if len(outOfChannelUsers) == 1 {
message = T("api.post.check_for_out_of_channel_mentions.message.one", map[string]interface{}{
"Username": ocUsernames[0],
})
} else if len(outOfChannelUsers) > 1 {
preliminary, final := splitAtFinal(ocUsernames)
message = T("api.post.check_for_out_of_channel_mentions.message.multiple", map[string]interface{}{
"Usernames": strings.Join(preliminary, ", @"),
"LastUsername": final,
})
}
if len(outOfGroupsUsers) == 1 {
if message != "" {
message += "\n"
}
message += T("api.post.check_for_out_of_channel_groups_mentions.message.one", map[string]interface{}{
"Username": ogUsernames[0],
})
} else if len(outOfGroupsUsers) > 1 {
preliminary, final := splitAtFinal(ogUsernames)
if message != "" {
message += "\n"
}
message += T("api.post.check_for_out_of_channel_groups_mentions.message.multiple", map[string]interface{}{
"Usernames": strings.Join(preliminary, ", @"),
"LastUsername": final,
})
}
props := model.StringInterface{
model.PropsAddChannelMember: model.StringInterface{
"post_id": ephemeralPostId,
"usernames": allUsers.Usernames(), // Kept for backwards compatibility of mobile app.
"not_in_channel_usernames": ocUsernames,
"user_ids": allUsers.IDs(), // Kept for backwards compatibility of mobile app.
"not_in_channel_user_ids": ocUserIDs,
"not_in_groups_usernames": ogUsernames,
"not_in_groups_user_ids": ogUsers.IDs(),
},
}
return &model.Post{
Id: ephemeralPostId,
RootId: post.RootId,
ChannelId: post.ChannelId,
Message: message,
CreateAt: post.CreateAt + 1,
Props: props,
}
}
func splitAtFinal(items []string) (preliminary []string, final string) {
if len(items) == 0 {
return
}
preliminary = items[:len(items)-1]
final = items[len(items)-1]
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 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{}
buf := ""
mentionsEnabledFields := getMentionsEnabledFields(post)
for _, message := range mentionsEnabledFields {
markdown.Inspect(message, func(node interface{}) bool {
text, ok := node.(*markdown.Text)
if !ok {
ret.processText(buf, keywords, groups)
buf = ""
return true
}
buf += text.Text
return false
})
}
ret.processText(buf, keywords, groups)
return ret
}
// Given a post returns the values of the fields in which mentions are possible.
// post.message, preText and text in the attachment are enabled.
func getMentionsEnabledFields(post *model.Post) model.StringArray {
ret := []string{}
ret = append(ret, post.Message)
for _, attachment := range post.Attachments() {
if attachment.Pretext != "" {
ret = append(ret, attachment.Pretext)
}
if attachment.Text != "" {
ret = append(ret, attachment.Text)
}
}
return ret
}
// allowChannelMentions returns whether or not the channel mentions are allowed for the given post.
func (a *App) allowChannelMentions(post *model.Post, numProfiles int) bool {
if !a.HasPermissionToChannel(post.UserId, post.ChannelId, model.PermissionUseChannelMentions) {
return false
}
if post.Type == model.PostTypeHeaderChange || post.Type == model.PostTypePurposeChange {
return false
}
if int64(numProfiles) >= *a.Config().TeamSettings.MaxNotificationsPerChannel {
return false
}
return true
}
// allowGroupMentions returns whether or not the group mentions are allowed for the given post.
func (a *App) allowGroupMentions(post *model.Post) bool {
if license := a.Srv().License(); license == nil || (license.SkuShortName != model.LicenseShortSkuProfessional && license.SkuShortName != model.LicenseShortSkuEnterprise) {
return false
}
if !a.HasPermissionToChannel(post.UserId, post.ChannelId, model.PermissionUseGroupMentions) {
return false
}
if post.Type == model.PostTypeHeaderChange || post.Type == model.PostTypePurposeChange {
return false
}
return true
}
// getGroupsAllowedForReferenceInChannel returns a map of groups allowed for reference in a given channel and team.
func (a *App) getGroupsAllowedForReferenceInChannel(channel *model.Channel, team *model.Team) (map[string]*model.Group, error) {
var err error
groupsMap := make(map[string]*model.Group)
opts := model.GroupSearchOpts{FilterAllowReference: true, IncludeMemberCount: true}
if channel.IsGroupConstrained() || (team != nil && team.IsGroupConstrained()) {
var groups []*model.GroupWithSchemeAdmin
if channel.IsGroupConstrained() {
groups, err = a.Srv().Store.Group().GetGroupsByChannel(channel.Id, opts)
} else {
groups, err = a.Srv().Store.Group().GetGroupsByTeam(team.Id, opts)
}
if err != nil {
return nil, errors.Wrap(err, "unable to get groups")
}
for _, group := range groups {
if group.Group.Name != nil {
groupsMap[*group.Group.Name] = &group.Group
}
}
return groupsMap, nil
}
groups, err := a.Srv().Store.Group().GetGroups(0, 0, opts)
if err != nil {
return nil, errors.Wrap(err, "unable to get groups")
}
for _, group := range groups {
if group.Name != nil {
groupsMap[*group.Name] = group
}
}
return groupsMap, nil
}
// 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)
for _, profile := range profiles {
addMentionKeywordsForUser(
keywords,
profile,
channelMemberNotifyPropsMap[profile.Id],
a.GetStatusFromCache(profile.Id),
allowChannelMentions,
)
}
return keywords
}
// insertGroupMentions adds group members in the channel to Mentions, adds group members not in the channel to OtherPotentialMentions
// returns false if no group members present in the team that the channel belongs to
func (a *App) insertGroupMentions(group *model.Group, channel *model.Channel, profileMap map[string]*model.User, mentions *ExplicitMentions) (bool, *model.AppError) {
var err error
var groupMembers []*model.User
outOfChannelGroupMembers := []*model.User{}
isGroupOrDirect := channel.IsGroupOrDirect()
if isGroupOrDirect {
groupMembers, err = a.Srv().Store.Group().GetMemberUsers(group.Id)
} else {
groupMembers, err = a.Srv().Store.Group().GetMemberUsersInTeam(group.Id, channel.TeamId)
}
if err != nil {
return false, model.NewAppError("insertGroupMentions", "app.select_error", nil, err.Error(), http.StatusInternalServerError)
}
if mentions.Mentions == nil {
mentions.Mentions = make(map[string]MentionType)
}
for _, member := range groupMembers {
if _, ok := profileMap[member.Id]; ok {
mentions.Mentions[member.Id] = GroupMention
} else {
outOfChannelGroupMembers = append(outOfChannelGroupMembers, member)
}
}
potentialGroupMembersMentioned := []string{}
for _, user := range outOfChannelGroupMembers {
potentialGroupMembersMentioned = append(potentialGroupMembersMentioned, user.Username)
}
if mentions.OtherPotentialMentions == nil {
mentions.OtherPotentialMentions = potentialGroupMembersMentioned
} else {
mentions.OtherPotentialMentions = append(mentions.OtherPotentialMentions, potentialGroupMembersMentioned...)
}
return isGroupOrDirect || len(groupMembers) > 0, nil
}
// addMentionKeywordsForUser adds the mention keywords for a given user to the given keyword map. Returns the provided keyword map.
func addMentionKeywordsForUser(keywords map[string][]string, profile *model.User, channelNotifyProps map[string]string, status *model.Status, allowChannelMentions bool) map[string][]string {
userMention := "@" + strings.ToLower(profile.Username)
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
Post *model.Post
ProfileMap map[string]*model.User
Sender *model.User
}
// Returns the name of the channel for this notification. For direct messages, this is the sender's name
// preceded by an at sign. For group messages, this is a comma-separated list of the members of the
// channel, with an option to exclude the recipient of the message from that list.
func (n *PostNotification) GetChannelName(userNameFormat, excludeId string) string {
switch n.Channel.Type {
case model.ChannelTypeDirect:
return n.Sender.GetDisplayNameWithPrefix(userNameFormat, "@")
case model.ChannelTypeGroup:
names := []string{}
for _, user := range n.ProfileMap {
if user.Id != excludeId {
names = append(names, user.GetDisplayName(userNameFormat))
}
}
sort.Strings(names)
return strings.Join(names, ", ")
default:
return n.Channel.DisplayName
}
}
// Returns the name of the sender of this notification, accounting for things like system messages
// and whether or not the username has been overridden by an integration.
func (n *PostNotification) GetSenderName(userNameFormat string, overridesAllowed bool) string {
if n.Post.IsSystemMessage() {
return i18n.T("system.message.name")
}
if overridesAllowed && n.Channel.Type != model.ChannelTypeDirect {
if value := n.Post.GetProps()["override_username"]; value != nil && n.Post.GetProp("from_webhook") == "true" {
if s, ok := value.(string); ok {
return s
}
}
}
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
}
data, err := a.Srv().Store.Preference().Get(user.Id, model.PreferenceCategoryDisplaySettings, model.PreferenceNameNameFormat)
if err != nil {
return *a.Config().TeamSettings.TeammateNameDisplay
}
return data.Value
}
type CRTNotifiers struct {
// Desktop contains the user IDs of thread followers to receive desktop notification
Desktop model.StringArray
// Email contains the user IDs of thread followers to receive email notification
Email model.StringArray
// Push contains the user IDs of thread followers to receive push notification
Push model.StringArray
}
func (c *CRTNotifiers) addFollowerToNotify(user *model.User, mentions *ExplicitMentions, channelMemberNotificationProps model.StringMap, channel *model.Channel) {
_, userWasMentioned := mentions.Mentions[user.Id]
notifyDesktop, notifyPush, notifyEmail := shouldUserNotifyCRT(user, userWasMentioned)
notifyChannelDesktop, notifyChannelPush := shouldChannelMemberNotifyCRT(channelMemberNotificationProps, userWasMentioned)
// respect the user global notify props when there are no channel specific ones (default)
// otherwise respect the channel member's notify props
if (channelMemberNotificationProps[model.DesktopNotifyProp] == model.ChannelNotifyDefault && notifyDesktop) || notifyChannelDesktop {
c.Desktop = append(c.Desktop, user.Id)
}
if notifyEmail {
c.Email = append(c.Email, user.Id)
}
// respect the user global notify props when there are no channel specific ones (default)
// otherwise respect the channel member's notify props
if (channelMemberNotificationProps[model.PushNotifyProp] == model.ChannelNotifyDefault && notifyPush) || notifyChannelPush {
c.Push = append(c.Push, user.Id)
}
}
// user global settings check for desktop, email, and push notifications
func shouldUserNotifyCRT(user *model.User, isMentioned bool) (notifyDesktop, notifyPush, notifyEmail bool) {
notifyDesktop = false
notifyPush = false
notifyEmail = false
desktop := user.NotifyProps[model.DesktopNotifyProp]
push := user.NotifyProps[model.PushNotifyProp]
shouldEmail := user.NotifyProps[model.EmailNotifyProp] == "true"
desktopThreads := user.NotifyProps[model.DesktopThreadsNotifyProp]
emailThreads := user.NotifyProps[model.EmailThreadsNotifyProp]
pushThreads := user.NotifyProps[model.PushThreadsNotifyProp]
// user should be notified via desktop notification in the case the notify prop is not set as no notify
// and either the user was mentioned or the CRT notify prop for desktop is set to all
if desktop != model.UserNotifyNone && (isMentioned || desktopThreads == model.UserNotifyAll || desktop == model.UserNotifyAll) {
notifyDesktop = true
}
// user should be notified via email when emailing is enabled and
// either the user was mentioned, or the CRT notify prop for email is set to all
if shouldEmail && (isMentioned || emailThreads == model.UserNotifyAll) {
notifyEmail = true
}
// user should be notified via push in the case the notify prop is not set as no notify
// and either the user was mentioned or the CRT push notify prop is set to all
if push != model.UserNotifyNone && (isMentioned || pushThreads == model.UserNotifyAll || push == model.UserNotifyAll) {
notifyPush = true
}
return
}
// channel specific settings check for desktop and push notifications
func shouldChannelMemberNotifyCRT(notifyProps model.StringMap, isMentioned bool) (notifyDesktop, notifyPush bool) {
notifyDesktop = false
notifyPush = false
desktop := notifyProps[model.DesktopNotifyProp]
push := notifyProps[model.PushNotifyProp]
desktopThreads := notifyProps[model.DesktopThreadsNotifyProp]
pushThreads := notifyProps[model.PushThreadsNotifyProp]
// user should be notified via desktop notification in the case the notify prop is not set as no notify or default
// and either the user was mentioned or the CRT notify prop for desktop is set to all
if desktop != model.ChannelNotifyDefault && desktop != model.ChannelNotifyNone && (isMentioned || desktopThreads == model.ChannelNotifyAll || desktop == model.ChannelNotifyAll) {
notifyDesktop = true
}
// user should be notified via push in the case the notify prop is not set as no notify or default
// and either the user was mentioned or the CRT push notify prop is set to all
if push != model.ChannelNotifyDefault && push != model.ChannelNotifyNone && (isMentioned || pushThreads == model.ChannelNotifyAll || push == model.ChannelNotifyAll) {
notifyPush = true
}
return
}