// 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" "github.com/pkg/errors" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/i18n" "github.com/mattermost/mattermost/server/public/shared/markdown" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" ) func (a *App) canSendPushNotifications() bool { if !*a.Config().EmailSettings.SendPushNotifications { a.Log().LogM(mlog.MlvlNotificationDebug, "Push notifications are disabled - server config", mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", "push_disabled"), ) return false } pushServer := *a.Config().EmailSettings.PushNotificationServer // Check for MHPNS servers (both current and legacy DNS aliases) isMHPNSServer := pushServer == model.MHPNS || pushServer == model.MHPNSLegacyUS || pushServer == model.MHPNSLegacyDE || pushServer == model.MHPNSGlobal || pushServer == model.MHPNSUS || pushServer == model.MHPNSEU || pushServer == model.MHPNSAP if license := a.Srv().License(); isMHPNSServer && (license == nil || !*license.Features.MHPNS) { a.Log().LogM(mlog.MlvlNotificationWarn, "Push notifications are disabled - license missing", mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", "push_disabled_license"), ) mlog.Warn("Push notifications have been disabled. Update your license or go to System Console > Environment > Push Notification Server to use a different server") return false } return true } func (a *App) SendNotifications(rctx request.CTX, 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().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled pchan := make(chan store.StoreResult[map[string]*model.User], 1) go func() { props, err := a.Srv().Store().User().GetAllProfilesInChannel(context.Background(), channel.Id, true) pchan <- store.StoreResult[map[string]*model.User]{Data: props, NErr: err} close(pchan) }() cmnchan := make(chan store.StoreResult[map[string]model.StringMap], 1) go func() { props, err := a.Srv().Store().Channel().GetAllChannelMembersNotifyPropsForChannel(channel.Id, true) cmnchan <- store.StoreResult[map[string]model.StringMap]{Data: props, NErr: err} close(cmnchan) }() var gchan chan store.StoreResult[map[string]*model.Group] if a.allowGroupMentions(rctx, post) { gchan = make(chan store.StoreResult[map[string]*model.Group], 1) go func() { groupsMap, err := a.getGroupsAllowedForReferenceInChannel(channel, team) gchan <- store.StoreResult[map[string]*model.Group]{Data: groupsMap, NErr: err} close(gchan) }() } var fchan chan store.StoreResult[[]*model.FileInfo] if len(post.FileIds) != 0 { fchan = make(chan store.StoreResult[[]*model.FileInfo], 1) go func() { fileInfos, err := a.Srv().Store().FileInfo().GetForPost(post.Id, true, false, true) fchan <- store.StoreResult[[]*model.FileInfo]{Data: fileInfos, NErr: err} close(fchan) }() } var tchan chan store.StoreResult[[]string] if isCRTAllowed && post.RootId != "" { tchan = make(chan store.StoreResult[[]string], 1) go func() { followers, err := a.Srv().Store().Thread().GetThreadFollowers(post.RootId, true) tchan <- store.StoreResult[[]string]{Data: followers, NErr: err} close(tchan) }() } pResult := <-pchan if pResult.NErr != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Error fetching profiles", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.Err(pResult.NErr), ) return nil, pResult.NErr } profileMap := pResult.Data cmnResult := <-cmnchan if cmnResult.NErr != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Error fetching notify props", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.Err(cmnResult.NErr), ) return nil, cmnResult.NErr } channelMemberNotifyPropsMap := cmnResult.Data followers := make(model.StringSet, 0) if tchan != nil { tResult := <-tchan if tResult.NErr != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Error fetching thread followers", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.Err(tResult.NErr), ) return nil, tResult.NErr } for _, v := range tResult.Data { followers.Add(v) } } groups := make(map[string]*model.Group) if gchan != nil { gResult := <-gchan if gResult.NErr != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Error fetching group mentions", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.Err(gResult.NErr), ) return nil, gResult.NErr } groups = gResult.Data } rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Successfully fetched all profiles", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), ) mentions, keywords := a.getExplicitMentionsAndKeywords(rctx, post, channel, profileMap, groups, channelMemberNotifyPropsMap, parentPostList) 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 groupID := range mentions.GroupMentions { group := groups[groupID] anyUsersMentionedByGroup, err := a.insertGroupMentions(sender.Id, group, channel, profileMap, mentions) if err != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Failed to populate group mentions", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.Err(err), ) return nil, err } if !anyUsersMentionedByGroup { a.sendNoUsersNotifiedByGroupInChannel(rctx, sender, post, channel, groups[groupID]) } } go func() { _, err := a.sendOutOfChannelMentions(rctx, sender, post, channel, mentions.OtherPotentialMentions) if err != nil { rctx.Logger().LogM(mlog.MlvlNotificationWarn, "Failed to send warning for out of channel mentions", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", "failed_to_send_out_of_channel"), mlog.Err(err), ) rctx.Logger().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(model.PostPropsFromWebhook) == "true") && !post.IsSystemMessage() && !(a.IsCRTEnabledForUser(rctx, 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 *MentionResults if parentPostList != nil { rootPost := parentPostList.Posts[parentPostList.Order[0]] if rootPost.GetProp(model.PostPropsFromWebhook) != "true" { if _, ok := profileMap[rootPost.UserId]; ok { threadParticipants[rootPost.UserId] = true } } if channel.Type != model.ChannelTypeDirect { rootMentions = getExplicitMentions(rootPost, keywords) for id := range rootMentions.Mentions { threadParticipants[id] = true } } } for id := range mentions.Mentions { threadParticipants[id] = true } if channel.Type != model.ChannelTypeDirect { for id, propsMap := range channelMemberNotifyPropsMap { if ok := followers.Has(id); !ok && propsMap[model.ChannelAutoFollowThreads] == model.ChannelAutoFollowThreadsOn { 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, "", http.StatusInternalServerError).Wrap(err) 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, "", http.StatusInternalServerError).Wrap(err) return } followersMutex.Lock() // add new followers to existing followers if ok := followers.Has(userID); !ok && threadMembership.Following { followers.Add(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 == "", post.IsUrgent()) if nErr != nil { rctx.Logger().Warn( "Failed to update mention count", mlog.String("post_id", post.Id), mlog.String("channel_id", post.ChannelId), mlog.Err(nErr), ) } rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Finished processing mentions", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), ) // Log the problems that might have occurred while auto following the thread for _, mac := range mentionAutofollowChans { if err := <-mac; err != nil { rctx.Logger().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(rctx, uid) { continue } if post.GetProp(model.PostPropsFromWebhook) != "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 { rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Begin sending email notifications", mlog.String("type", model.NotificationTypeEmail), mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), ) emailRecipients := append(mentionedUsersList, notificationsForCRT.Email...) emailRecipients = model.RemoveDuplicateStrings(emailRecipients) for _, id := range emailRecipients { if profileMap[id] == nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeEmail, model.NotificationReasonMissingProfile, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile", mlog.String("type", model.NotificationTypeEmail), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", model.NotificationReasonMissingProfile), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", id), ) continue } // If email verification is required and user email is not verified don't send email. if *a.Config().EmailSettings.RequireEmailVerification && !profileMap[id].EmailVerified { a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypeEmail, model.NotificationReasonEmailNotVerified, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationDebug, "Email not verified", mlog.String("type", model.NotificationTypeEmail), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", model.NotificationReasonEmailNotVerified), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", id), ) rctx.Logger().Debug("Skipped sending notification email, address not verified.", mlog.String("user_email", profileMap[id].Email), mlog.String("user_id", id)) continue } if a.userAllowsEmail(rctx, profileMap[id], channelMemberNotifyPropsMap[id], post) { senderProfileImage, _, err := a.GetProfileImage(sender) if err != nil { rctx.Logger().Warn("Unable to get the sender user profile image.", mlog.String("user_id", sender.Id), mlog.Err(err)) } a.Srv().Go(func() { if _, err := a.sendNotificationEmail(rctx, notification, profileMap[id], team, senderProfileImage); err != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeEmail, model.NotificationReasonEmailSendError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Error sending email notification", mlog.String("type", model.NotificationTypeEmail), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonEmailSendError), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", id), mlog.Err(err), ) rctx.Logger().Warn("Unable to send notification email.", mlog.Err(err)) } }) } else { rctx.Logger().LogM(mlog.MlvlNotificationDebug, "Email disallowed by user", mlog.String("type", model.NotificationTypeEmail), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", "email_disallowed_by_user"), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", id), ) } } rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Finished sending email notifications", mlog.String("type", model.NotificationTypeEmail), mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), ) } // Check for channel-wide mentions in channels that have too many members for those to work if int64(len(profileMap)) > *a.Config().TeamSettings.MaxNotificationsPerChannel { a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypeAll, model.NotificationReasonTooManyUsersInChannel, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationDebug, "Too many users to notify - will send ephemeral message", mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", model.NotificationReasonTooManyUsersInChannel), ) T := i18n.GetUserTranslations(sender.Locale) if mentions.HereMentioned { a.SendEphemeralPost( rctx, post.UserId, &model.Post{ ChannelId: post.ChannelId, Message: T("api.post.disabled_here", map[string]any{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}), CreateAt: post.CreateAt + 1, }, ) } if mentions.ChannelMentioned { a.SendEphemeralPost( rctx, post.UserId, &model.Post{ ChannelId: post.ChannelId, Message: T("api.post.disabled_channel", map[string]any{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}), CreateAt: post.CreateAt + 1, }, ) } if mentions.AllMentioned { a.SendEphemeralPost( rctx, post.UserId, &model.Post{ ChannelId: post.ChannelId, Message: T("api.post.disabled_all", map[string]any{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}), CreateAt: post.CreateAt + 1, }, ) } } if a.canSendPushNotifications() { rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Begin sending push notifications", mlog.String("type", model.NotificationTypePush), mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), ) for _, id := range mentionedUsersList { if profileMap[id] == nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypePush, model.NotificationReasonMissingProfile, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile", mlog.String("type", model.NotificationTypePush), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", model.NotificationReasonMissingProfile), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", id), ) continue } if notificationsForCRT.Push.Contains(id) { rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Skipped direct push notification - will send as CRT notification", mlog.String("type", model.NotificationTypePush), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("sender_id", sender.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: ""} } isExplicitlyMentioned := mentions.Mentions[id] > GMMention isGM := channel.Type == model.ChannelTypeGroup if a.ShouldSendPushNotification(rctx, profileMap[id], channelMemberNotifyPropsMap[id], isExplicitlyMentioned, status, post, isGM) { 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, ) } } for _, id := range allActivityPushUserIds { if profileMap[id] == nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypePush, model.NotificationReasonMissingProfile, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile", mlog.String("type", model.NotificationTypePush), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonMissingProfile), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", id), ) continue } if notificationsForCRT.Push.Contains(id) { rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Skipped direct push notification - will send as CRT notification", mlog.String("type", model.NotificationTypePush), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("sender_id", sender.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: ""} } isGM := channel.Type == model.ChannelTypeGroup if a.ShouldSendPushNotification(rctx, profileMap[id], channelMemberNotifyPropsMap[id], false, status, post, isGM) { a.sendPushNotification( notification, profileMap[id], false, false, "", ) } } } for _, id := range notificationsForCRT.Push { if profileMap[id] == nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypePush, model.NotificationReasonMissingProfile, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile", mlog.String("type", model.NotificationTypePush), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonMissingProfile), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", 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 statusReason := doesStatusAllowPushNotification(profileMap[id].NotifyProps, status, post.ChannelId, true); statusReason == "" { a.sendPushNotification( notification, profileMap[id], false, false, model.CommentsNotifyCRT, ) } else { a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypePush, statusReason, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationDebug, "Notification not sent - status", mlog.String("type", model.NotificationTypePush), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", statusReason), mlog.String("status_reason", statusReason), mlog.String("sender_id", post.UserId), mlog.String("receiver_id", id), mlog.String("receiver_status", status.Status), ) } } rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Finished sending push notifications", mlog.String("type", model.NotificationTypePush), mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), ) } rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Begin sending websocket notifications", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), ) message := model.NewWebSocketEvent(model.WebsocketEventPosted, "", post.ChannelId, "", nil, "") 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 fResult := <-fchan; fResult.NErr != nil { rctx.Logger().Warn("Unable to get fileInfo for push notifications.", mlog.String("post_id", post.Id), mlog.Err(fResult.NErr)) } else { infos = fResult.Data } for _, info := range infos { if info.IsImage() { message.Add("image", "true") break } } } if len(mentionedUsersList) > 0 { useAddMentionsHook(message, mentionedUsersList) } if len(notificationsForCRT.Desktop) > 0 { useAddFollowersHook(message, notificationsForCRT.Desktop) } // Collect user IDs of whom we want to acknowledge the websocket event for notification metrics usersToAck := []string{} for id, profile := range profileMap { userNotificationLevel := profile.NotifyProps[model.DesktopNotifyProp] channelNotificationLevel := channelMemberNotifyPropsMap[id][model.DesktopNotifyProp] if shouldAckWebsocketNotification(channel.Type, userNotificationLevel, channelNotificationLevel) { usersToAck = append(usersToAck, id) } } usePostedAckHook(message, post.UserId, channel.Type, usersToAck) appErr := a.publishWebsocketEventForPost(rctx, post, message) if appErr != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Couldn't send websocket notification for permalink post", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.String("sender_id", sender.Id), mlog.Err(appErr), ) return nil, appErr } // 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 { // This also sometimes happens when bots, which will never show up in the map, reply to threads // Their own post goes through this and they get "notified", which we don't need to count as an error if they can't if uid != post.UserId { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonMissingProfile, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonMissingProfile), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", uid), ) } continue } if a.IsCRTEnabledForUser(rctx, 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 { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing thread membership", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", uid), mlog.Err(err), ) return nil, errors.Wrapf(err, "Missing thread membership for participant in notifications. user_id=%q thread_id=%q", uid, post.RootId) } if tm == nil { a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypeWebsocket, model.NotificationReasonMissingThreadMembership, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationWarn, "Missing thread membership", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusNotSent), mlog.String("reason", model.NotificationReasonMissingThreadMembership), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", uid), ) continue } threadMembership = tm } userThread, err := a.Srv().Store().Thread().GetThreadForUser(rctx, threadMembership, true, a.IsPostPriorityEnabled()) if err != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing thread", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", uid), mlog.Err(err), ) 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 { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonFetchError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Failed to update thread membership", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonFetchError), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", uid), mlog.Err(err), ) 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(rctx, userThread.Post, uid) if err != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonParseError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Failed to sanitize metadata", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("post_id", post.Id), mlog.String("status", model.NotificationStatusError), mlog.String("reason", model.NotificationReasonParseError), mlog.String("sender_id", sender.Id), mlog.String("receiver_id", uid), mlog.Err(err), ) return nil, err } userThread.Post = sanitizedPost payload, jsonErr := json.Marshal(userThread) if jsonErr != nil { rctx.Logger().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) } } } } a.Log().LogM(mlog.MlvlNotificationTrace, "Finish sending websocket notifications", mlog.String("type", model.NotificationTypeWebsocket), mlog.String("sender_id", sender.Id), mlog.String("post_id", post.Id), ) return mentionedUsersList, nil } func (a *App) RemoveNotifications(rctx request.CTX, post *model.Post, channel *model.Channel) error { isCRTAllowed := *a.Config().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled // CRT is the main issue in this case as notifications indicator are not updated when accessing threads from the sidebar. if isCRTAllowed && post.RootId != "" { var team *model.Team if channel.TeamId != "" { t, err1 := a.Srv().Store().Team().Get(channel.TeamId) if err1 != nil { return model.NewAppError("RemoveNotifications", "app.post.delete_post.get_team.app_error", nil, "", http.StatusInternalServerError).Wrap(err1) } team = t } else { // Blank team for DMs team = &model.Team{} } pCh := make(chan store.StoreResult[map[string]*model.User], 1) go func() { props, err := a.Srv().Store().User().GetAllProfilesInChannel(context.Background(), channel.Id, true) pCh <- store.StoreResult[map[string]*model.User]{Data: props, NErr: err} close(pCh) }() cmnCh := make(chan store.StoreResult[map[string]model.StringMap], 1) go func() { props, err := a.Srv().Store().Channel().GetAllChannelMembersNotifyPropsForChannel(channel.Id, true) cmnCh <- store.StoreResult[map[string]model.StringMap]{Data: props, NErr: err} close(cmnCh) }() var gCh chan store.StoreResult[map[string]*model.Group] if a.allowGroupMentions(rctx, post) { gCh = make(chan store.StoreResult[map[string]*model.Group], 1) go func() { groupsMap, err := a.getGroupsAllowedForReferenceInChannel(channel, team) gCh <- store.StoreResult[map[string]*model.Group]{Data: groupsMap, NErr: err} close(gCh) }() } resultP := <-pCh if resultP.NErr != nil { return resultP.NErr } profileMap := resultP.Data resultCmn := <-cmnCh if resultCmn.NErr != nil { return resultCmn.NErr } channelMemberNotifyPropsMap := resultCmn.Data groups := make(map[string]*model.Group) if gCh != nil { resultG := <-gCh if resultG.NErr != nil { return resultG.NErr } groups = resultG.Data } mentions, _ := a.getExplicitMentionsAndKeywords(rctx, post, channel, profileMap, groups, channelMemberNotifyPropsMap, nil) userIDs := []string{} for groupID := range mentions.GroupMentions { for page := 0; ; page++ { groupMemberPage, count, appErr := a.GetGroupMemberUsersPage(groupID, page, 100, &model.ViewUsersRestrictions{Channels: []string{channel.Id}}) if appErr != nil { return appErr } for _, user := range groupMemberPage { userIDs = append(userIDs, user.Id) } // count is the total number of users that match the filter criteria. // When we've processed `count` number of users, we know there aren't // any more users left to query and we can break the loop if len(userIDs) == count { break } } } for userID := range mentions.Mentions { userIDs = append(userIDs, userID) } for _, userID := range userIDs { threadMembership, appErr := a.GetThreadMembershipForUser(userID, post.RootId) if appErr != nil { return appErr } // If the user has viewed the thread or there are no unread mentions, skip. if threadMembership.LastViewed > post.CreateAt || threadMembership.UnreadMentions == 0 { continue } threadMembership.UnreadMentions -= 1 if _, err := a.Srv().Store().Thread().UpdateMembership(threadMembership); err != nil { return err } userThread, err := a.Srv().Store().Thread().GetThreadForUser(rctx, threadMembership, true, a.IsPostPriorityEnabled()) if err != nil { return err } if userThread != nil { previousUnreadMentions := int64(0) previousUnreadReplies := int64(0) a.sanitizeProfiles(userThread.Participants, false) userThread.Post.SanitizeProps() sanitizedPost, err1 := a.SanitizePostMetadataForUser(rctx, userThread.Post, userID) if err1 != nil { return err1 } userThread.Post = sanitizedPost payload, jsonErr := json.Marshal(userThread) if jsonErr != nil { rctx.Logger().Warn("Failed to encode thread to JSON") } message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, team.Id, "", userID, nil, "") message.Add("thread", string(payload)) message.Add("previous_unread_mentions", previousUnreadMentions) message.Add("previous_unread_replies", previousUnreadReplies) a.Publish(message) } } } return nil } func (a *App) getExplicitMentionsAndKeywords(rctx 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 MentionKeywords if channel.Type == model.ChannelTypeDirect { isWebhook := post.GetProp(model.PostPropsFromWebhook) == "true" // A bot can post in a DM where it doesn't belong to. // Therefore, we cannot "guess" who is the other user, // so we add the mention to any user that is not the // poster unless the post comes from a webhook. user1, user2 := channel.GetBothUsersForDM() if (post.UserId != user1) || isWebhook { if _, ok := profileMap[user1]; ok { mentions.addMention(user1, DMMention) } else { a.Log().Debug("missing profile: DM user not in profiles", mlog.String("userId", user1), mlog.String("channelId", channel.Id)) } } if user2 != "" { if (post.UserId != user2) || isWebhook { if _, ok := profileMap[user2]; ok { mentions.addMention(user2, DMMention) } else { a.Log().Debug("missing profile: DM user not in profiles", mlog.String("userId", user2), mlog.String("channelId", channel.Id)) } } } } else { allowChannelMentions = a.allowChannelMentions(rctx, post, len(profileMap)) keywords = a.getMentionKeywordsInChannel(profileMap, allowChannelMentions, channelMemberNotifyPropsMap, groups) mentions = getExplicitMentions(post, keywords) // Add a GM mention to all members of a GM channel if channel.Type == model.ChannelTypeGroup { for id := range channelMemberNotifyPropsMap { if _, ok := profileMap[id]; ok { mentions.addMention(id, GMMention) } else { a.Log().Debug("missing profile: GM user not in profiles", mlog.String("userId", id), mlog.String("channelId", channel.Id)) } } } // 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 { if addedUserId, ok := post.GetProp(model.PostPropsAddedUserId).(string); ok { if _, ok := profileMap[addedUserId]; ok { mentions.addMention(addedUserId, KeywordMention) } else { a.Log().Debug("missing profile: user added to channel not in profiles", mlog.String("userId", addedUserId), mlog.String("channelId", channel.Id)) } } } // 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 { // Not logging missing profile since this is relatively expected 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(rctx, 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(model.PostPropsFromWebhook) != "true" { mentions.removeMention(post.UserId) } } return mentions, keywords } func (a *App) userAllowsEmail(rctx request.CTX, user *model.User, channelMemberNotificationProps model.StringMap, post *model.Post) bool { // if user is a bot account or remote, then we do not send email if user.IsBot || user.IsRemote() { 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(rctx, 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 { rctx.Logger().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(rctx request.CTX, 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(rctx, 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(rctx request.CTX, sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) (bool, error) { outOfTeamUsers, outOfChannelUsers, outOfGroupsUsers, err := a.filterOutOfChannelMentions(rctx, sender, post, channel, potentialMentions) if err != nil { return false, err } if len(outOfTeamUsers) == 0 && len(outOfChannelUsers) == 0 && len(outOfGroupsUsers) == 0 { return false, nil } if len(outOfChannelUsers) != 0 || len(outOfGroupsUsers) != 0 { a.SendEphemeralPost(rctx, post.UserId, makeOutOfChannelMentionPost(sender, post, outOfChannelUsers, outOfGroupsUsers)) } if len(outOfTeamUsers) != 0 { a.SendEphemeralPost(rctx, post.UserId, makeOutOfTeamMentionPost(sender, post, outOfTeamUsers)) } return true, nil } func (a *App) FilterUsersByVisible(rctx request.CTX, viewer *model.User, otherUsers []*model.User) ([]*model.User, *model.AppError) { result := []*model.User{} for _, user := range otherUsers { canSee, err := a.UserCanSeeOtherUser(rctx, viewer.Id, user.Id) if err != nil { return nil, err } if canSee { result = append(result, user) } } return result, nil } func (a *App) filterOutOfChannelMentions(rctx request.CTX, sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) ([]*model.User, []*model.User, []*model.User, error) { if post.IsSystemMessage() { return nil, nil, nil, nil } if channel.TeamId == "" || channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup { return nil, nil, nil, nil } if len(potentialMentions) == 0 { return nil, nil, nil, nil } mentionedUsersInTheTeam, err := a.Srv().Store().User().GetProfilesByUsernames(potentialMentions, &model.ViewUsersRestrictions{Teams: []string{channel.TeamId}}) if err != nil { return nil, nil, nil, err } // Filter out inactive users and bots teamUsers := model.UserSlice(mentionedUsersInTheTeam).FilterByActive(true) teamUsers = teamUsers.FilterWithoutBots() teamUsers, appErr := a.FilterUsersByVisible(rctx, sender, teamUsers) if appErr != nil { return nil, nil, nil, appErr } allMentionedUsers, err := a.Srv().Store().User().GetProfilesByUsernames(potentialMentions, nil) if err != nil { return nil, nil, nil, err } outOfTeamUsers := model.UserSlice(allMentionedUsers).FilterWithoutID(teamUsers.IDs()) outOfTeamUsers = outOfTeamUsers.FilterByActive(true) outOfTeamUsers = outOfTeamUsers.FilterWithoutBots() outOfTeamUsers, appErr = a.FilterUsersByVisible(rctx, sender, outOfTeamUsers) if appErr != nil { return nil, nil, nil, appErr } if len(teamUsers) == 0 { return outOfTeamUsers, nil, nil, nil } // Differentiate between mentionedUsersInTheTeam 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(rctx, teamUsers.IDs(), channel) if err != nil { return nil, nil, nil, err } outOfChannelUsers = teamUsers.FilterWithoutID(nonMemberIDs) outOfGroupsUsers = teamUsers.FilterByID(nonMemberIDs) } else { outOfChannelUsers = teamUsers } return outOfTeamUsers, 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 // Generate message for users who can be invited if len(outOfChannelUsers) == 1 { message = T("api.post.check_for_out_of_channel_mentions.message.one", map[string]any{ "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]any{ "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]any{ "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]any{ "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 makeOutOfTeamMentionPost(sender *model.User, post *model.Post, outOfTeamUsers []*model.User) *model.Post { otUsers := model.UserSlice(outOfTeamUsers) otUsernames := otUsers.Usernames() T := i18n.GetUserTranslations(sender.Locale) ephemeralPostId := model.NewId() var message string if len(outOfTeamUsers) == 1 { message += T("api.post.check_for_out_of_team_mentions.message.one", map[string]any{ "Username": otUsernames[0], }) } else if len(outOfTeamUsers) > 1 { preliminary, final := splitAtFinal(otUsernames) message += T("api.post.check_for_out_of_team_mentions.message.multiple", map[string]any{ "Usernames": strings.Join(preliminary, ", @"), "LastUsername": final, }) } return &model.Post{ Id: ephemeralPostId, RootId: post.RootId, ChannelId: post.ChannelId, Message: message, CreateAt: post.CreateAt + 1, } } func splitAtFinal(items []string) (preliminary []string, final string) { if len(items) == 0 { return } preliminary = items[:len(items)-1] final = items[len(items)-1] return } // 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 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 { // 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 }) } // 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. // 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) } for _, field := range attachment.Fields { if valueString, ok := field.Value.(string); ok && valueString != "" { ret = append(ret, valueString) } } } return ret } // allowChannelMentions returns whether or not the channel mentions are allowed for the given post. func (a *App) allowChannelMentions(rctx request.CTX, post *model.Post, numProfiles int) bool { if !a.HasPermissionToChannel(rctx, 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(rctx request.CTX, post *model.Post) bool { if !model.MinimumProfessionalLicense(a.Srv().License()) { return false } if !a.HasPermissionToChannel(rctx, 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.Id] = &group.Group } } opts.Source = model.GroupSourceCustom var customgroups []*model.Group customgroups, err = a.Srv().Store().Group().GetGroups(0, 0, opts, nil) if err != nil { return nil, errors.Wrap(err, "unable to get custom groups") } for _, group := range customgroups { if group.Name != nil { groupsMap[group.Id] = group } } return groupsMap, nil } groups, err := a.Srv().Store().Group().GetGroups(0, 0, opts, nil) if err != nil { return nil, errors.Wrap(err, "unable to get groups") } for _, group := range groups { if group.Name != nil { groupsMap[group.Id] = 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, groups map[string]*model.Group) MentionKeywords { keywords := make(MentionKeywords) for _, profile := range profiles { keywords.AddUser( profile, channelMemberNotifyPropsMap[profile.Id], a.GetStatusFromCache(profile.Id), allowChannelMentions, ) } 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(senderID string, 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{} 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, "", http.StatusInternalServerError).Wrap(err) } if mentions.Mentions == nil { mentions.Mentions = make(map[string]MentionType) } for _, member := range groupMembers { if member.Id != senderID { 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 } // 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.GetProp(model.PostPropsOverrideUsername); value != nil && n.Post.GetProp(model.PostPropsFromWebhook) == "true" { if s, ok := value.(string); ok { return s } } } return n.Sender.GetDisplayNameWithPrefix(userNameFormat, "@") } 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 *MentionResults, channelMemberNotificationProps model.StringMap, channel *model.Channel) { _, userWasMentioned := mentions.Mentions[user.Id] notifyDesktop, notifyPush, notifyEmail := shouldUserNotifyCRT(user, userWasMentioned) notifyChannelDesktop, notifyChannelPush := shouldChannelMemberNotifyCRT(user.NotifyProps, 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(userNotifyProps model.StringMap, channelMemberNotifyProps model.StringMap, isMentioned bool) (notifyDesktop, notifyPush bool) { notifyDesktop = false notifyPush = false desktop := channelMemberNotifyProps[model.DesktopNotifyProp] push := channelMemberNotifyProps[model.PushNotifyProp] desktopThreads := channelMemberNotifyProps[model.DesktopThreadsNotifyProp] userDesktopThreads := userNotifyProps[model.DesktopThreadsNotifyProp] pushThreads := channelMemberNotifyProps[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 && userDesktopThreads != model.UserNotifyMention) || 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 } func shouldAckWebsocketNotification(channelType model.ChannelType, userNotificationLevel, channelNotificationLevel string) bool { if channelNotificationLevel == model.ChannelNotifyAll { // Should ACK on if we notify for all messages in the channel return true } else if channelNotificationLevel == model.ChannelNotifyDefault && userNotificationLevel == model.UserNotifyAll { // Should ACK on if we notify for all messages and the channel settings are unchanged return true } else if channelType == model.ChannelTypeGroup && ((channelNotificationLevel == model.ChannelNotifyDefault && userNotificationLevel == model.UserNotifyMention) || channelNotificationLevel == model.ChannelNotifyMention) { // Should ACK for group channels where default settings are in place (should be notified) return true } return false } func (a *App) CountNotification(notificationType model.NotificationType, platform string) { if a.notificationMetricsDisabled() { return } a.Metrics().IncrementNotificationCounter(notificationType, platform) } func (a *App) CountNotificationAck(notificationType model.NotificationType, platform string) { if a.notificationMetricsDisabled() { return } a.Metrics().IncrementNotificationAckCounter(notificationType, platform) } func (a *App) CountNotificationReason( notificationStatus model.NotificationStatus, notificationType model.NotificationType, notificationReason model.NotificationReason, platform string, ) { if a.notificationMetricsDisabled() { return } switch notificationStatus { case model.NotificationStatusSuccess: a.Metrics().IncrementNotificationSuccessCounter(notificationType, platform) case model.NotificationStatusError: a.Metrics().IncrementNotificationErrorCounter(notificationType, notificationReason, platform) case model.NotificationStatusNotSent: a.Metrics().IncrementNotificationNotSentCounter(notificationType, notificationReason, platform) case model.NotificationStatusUnsupported: a.Metrics().IncrementNotificationUnsupportedCounter(notificationType, notificationReason, platform) } } func (a *App) notificationMetricsDisabled() bool { if a.Metrics() == nil { return true } if a.Config().FeatureFlags.NotificationMonitoring && *a.Config().MetricsSettings.EnableNotificationMetrics { return false } return true }