Files
mattermost/app/notification_push.go
Kyriakos Z a0c5d8feab MM-36234,MM-37030,MM-37031: CRT, desktop thread notifications (#18088)
* CRT: desktop thread notifications

* Fixes go lint

* Adds default for desktop CRT notifications

* Adds email and push notifications for CRT threads

Adds user ids of thread followers with CRT to crtMentions so they will get
notified appropriately.

* Minor change

* Refactor a bit

CRTMentions.addMention had a bug on the return and de-duplication.
This commit fixes duplicate notifications by looking up if the user is to be
notified on CRT on both email and push notifications.

* Minor refactor

* Changes according to review comments

- Fixes adding to followers a user that had explicitly unfollowed a
  thread.
- Simplified send email according to email_threads option
- Send mentions and followers in separate arrays via the websocket
- Fixes push notifications message for push_threads

* Adds a comment on a buggy use case

* Updates comment to correct ticket link

* Fixes when user notifications is set to all

There was a bug where if user had set notifications to all
then they would receive desktop notifications even for non following threads.

A similar bug existed in push notifications, where if a user has set it
to all the threads setting would still be considered.

This commit fixes that by adding users to notificationsForCRT
StringArray when they have the non thread setting to 'all'.

* Fixes notifications to users unfollowing threads

Users which had previously explicitly unfollowed a thread
should not receive notifications about those threads.

* Update store mocks

* Fixes push notifications for CRT

Push notification about replies for CRT users should have a title of
"Reply to Thread".

CRT users with global user setting to 'UserNotifyAll' should not get
notifications for unfollowed threads.

This commit fixes those issues.

* Fixes i18n error

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
2021-08-19 17:28:46 +03:00

618 lines
19 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"io"
"io/ioutil"
"net/http"
"runtime"
"strings"
"sync"
"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/mlog"
"github.com/mattermost/mattermost-server/v6/utils"
)
type notificationType string
const (
notificationTypeClear notificationType = "clear"
notificationTypeMessage notificationType = "message"
notificationTypeUpdateBadge notificationType = "update_badge"
notificationTypeDummy notificationType = "dummy"
)
type PushNotificationsHub struct {
notificationsChan chan PushNotification
app *App // XXX: This will go away once push notifications move to their own package.
sema chan struct{}
stopChan chan struct{}
wg *sync.WaitGroup
semaWg *sync.WaitGroup
buffer int
}
type PushNotification struct {
notificationType notificationType
currentSessionId string
userID string
channelID string
post *model.Post
user *model.User
channel *model.Channel
senderName string
channelName string
explicitMention bool
channelWideMention bool
replyToThreadType string
}
func (a *App) sendPushNotificationSync(post *model.Post, user *model.User, channel *model.Channel, channelName string, senderName string,
explicitMention bool, channelWideMention bool, replyToThreadType string) *model.AppError {
cfg := a.Config()
msg, appErr := a.BuildPushNotificationMessage(
*cfg.EmailSettings.PushNotificationContents,
post,
user,
channel,
channelName,
senderName,
explicitMention,
channelWideMention,
replyToThreadType,
)
if appErr != nil {
return appErr
}
return a.sendPushNotificationToAllSessions(msg, user.Id, "")
}
func (a *App) sendPushNotificationToAllSessions(msg *model.PushNotification, userID string, skipSessionId string) *model.AppError {
sessions, err := a.getMobileAppSessions(userID)
if err != nil {
return err
}
if msg == nil {
return model.NewAppError(
"pushNotification",
"api.push_notifications.message.parse.app_error",
nil,
"",
http.StatusBadRequest,
)
}
for _, session := range sessions {
// Don't send notifications to this session if it's expired or we want to skip it
if session.IsExpired() || (skipSessionId != "" && skipSessionId == session.Id) {
continue
}
// We made a copy to avoid decoding and parsing all the time
tmpMessage := msg.DeepCopy()
tmpMessage.SetDeviceIdAndPlatform(session.DeviceId)
tmpMessage.AckId = model.NewId()
err := a.sendToPushProxy(tmpMessage, session)
if err != nil {
a.NotificationsLog().Error("Notification error",
mlog.String("ackId", tmpMessage.AckId),
mlog.String("type", tmpMessage.Type),
mlog.String("userId", session.UserId),
mlog.String("postId", tmpMessage.PostId),
mlog.String("channelId", tmpMessage.ChannelId),
mlog.String("deviceId", tmpMessage.DeviceId),
mlog.String("status", err.Error()),
)
continue
}
a.NotificationsLog().Info("Notification sent",
mlog.String("ackId", tmpMessage.AckId),
mlog.String("type", tmpMessage.Type),
mlog.String("userId", session.UserId),
mlog.String("postId", tmpMessage.PostId),
mlog.String("channelId", tmpMessage.ChannelId),
mlog.String("deviceId", tmpMessage.DeviceId),
mlog.String("status", model.PushSendSuccess),
)
if a.Metrics() != nil {
a.Metrics().IncrementPostSentPush()
}
}
return nil
}
func (a *App) sendPushNotification(notification *PostNotification, user *model.User, explicitMention, channelWideMention bool, replyToThreadType string) {
cfg := a.Config()
channel := notification.Channel
post := notification.Post
nameFormat := a.GetNotificationNameFormat(user)
channelName := notification.GetChannelName(nameFormat, user.Id)
senderName := notification.GetSenderName(nameFormat, *cfg.ServiceSettings.EnablePostUsernameOverride)
select {
case a.Srv().PushNotificationsHub.notificationsChan <- PushNotification{
notificationType: notificationTypeMessage,
post: post,
user: user,
channel: channel,
senderName: senderName,
channelName: channelName,
explicitMention: explicitMention,
channelWideMention: channelWideMention,
replyToThreadType: replyToThreadType,
}:
case <-a.Srv().PushNotificationsHub.stopChan:
return
}
}
func (a *App) getPushNotificationMessage(contentsConfig, postMessage string, explicitMention, channelWideMention,
hasFiles bool, senderName string, channelType model.ChannelType, replyToThreadType string, userLocale i18n.TranslateFunc) string {
// If the post only has images then push an appropriate message
if postMessage == "" && hasFiles {
if channelType == model.ChannelTypeDirect {
return strings.Trim(userLocale("api.post.send_notifications_and_forget.push_image_only"), " ")
}
return senderName + userLocale("api.post.send_notifications_and_forget.push_image_only")
}
if contentsConfig == model.FullNotification {
if channelType == model.ChannelTypeDirect {
return model.ClearMentionTags(postMessage)
}
return senderName + ": " + model.ClearMentionTags(postMessage)
}
if channelType == model.ChannelTypeDirect {
return userLocale("api.post.send_notifications_and_forget.push_message")
}
if channelWideMention {
return senderName + userLocale("api.post.send_notification_and_forget.push_channel_mention")
}
if explicitMention {
return senderName + userLocale("api.post.send_notifications_and_forget.push_explicit_mention")
}
if replyToThreadType == model.CommentsNotifyRoot {
return senderName + userLocale("api.post.send_notification_and_forget.push_comment_on_post")
}
if replyToThreadType == model.CommentsNotifyAny {
return senderName + userLocale("api.post.send_notification_and_forget.push_comment_on_thread")
}
if replyToThreadType == model.UserNotifyAll {
return senderName + userLocale("api.post.send_notification_and_forget.push_comment_on_crt_thread")
}
return senderName + userLocale("api.post.send_notifications_and_forget.push_general_message")
}
func (a *App) clearPushNotificationSync(currentSessionId, userID, channelID string) *model.AppError {
msg := &model.PushNotification{
Type: model.PushTypeClear,
Version: model.PushMessageV2,
ChannelId: channelID,
ContentAvailable: 1,
}
unreadCount, err := a.Srv().Store.User().GetUnreadCount(userID)
if err != nil {
return model.NewAppError("clearPushNotificationSync", "app.user.get_unread_count.app_error", nil, err.Error(), http.StatusInternalServerError)
}
msg.Badge = int(unreadCount)
return a.sendPushNotificationToAllSessions(msg, userID, currentSessionId)
}
func (a *App) clearPushNotification(currentSessionId, userID, channelID string) {
select {
case a.Srv().PushNotificationsHub.notificationsChan <- PushNotification{
notificationType: notificationTypeClear,
currentSessionId: currentSessionId,
userID: userID,
channelID: channelID,
}:
case <-a.Srv().PushNotificationsHub.stopChan:
return
}
}
func (a *App) updateMobileAppBadgeSync(userID string) *model.AppError {
msg := &model.PushNotification{
Type: model.PushTypeUpdateBadge,
Version: model.PushMessageV2,
Sound: "none",
ContentAvailable: 1,
}
unreadCount, err := a.Srv().Store.User().GetUnreadCount(userID)
if err != nil {
return model.NewAppError("updateMobileAppBadgeSync", "app.user.get_unread_count.app_error", nil, err.Error(), http.StatusInternalServerError)
}
msg.Badge = int(unreadCount)
return a.sendPushNotificationToAllSessions(msg, userID, "")
}
func (a *App) UpdateMobileAppBadge(userID string) {
select {
case a.Srv().PushNotificationsHub.notificationsChan <- PushNotification{
notificationType: notificationTypeUpdateBadge,
userID: userID,
}:
case <-a.Srv().PushNotificationsHub.stopChan:
return
}
}
func (s *Server) createPushNotificationsHub() {
buffer := *s.Config().EmailSettings.PushNotificationBuffer
hub := PushNotificationsHub{
notificationsChan: make(chan PushNotification, buffer),
app: New(ServerConnector(s)),
wg: new(sync.WaitGroup),
semaWg: new(sync.WaitGroup),
sema: make(chan struct{}, runtime.NumCPU()*8), // numCPU * 8 is a good amount of concurrency.
stopChan: make(chan struct{}),
buffer: buffer,
}
go hub.start()
s.PushNotificationsHub = hub
}
func (hub *PushNotificationsHub) start() {
hub.wg.Add(1)
defer hub.wg.Done()
for {
select {
case notification := <-hub.notificationsChan:
// We just ignore dummy notifications.
// These are used to pump out any remaining notifications
// before we stop the hub.
if notification.notificationType == notificationTypeDummy {
continue
}
// Adding to the waitgroup first.
hub.semaWg.Add(1)
// Get token.
hub.sema <- struct{}{}
go func(notification PushNotification) {
defer func() {
// Release token.
<-hub.sema
// Now marking waitgroup as done.
hub.semaWg.Done()
}()
var err *model.AppError
switch notification.notificationType {
case notificationTypeClear:
err = hub.app.clearPushNotificationSync(notification.currentSessionId, notification.userID, notification.channelID)
case notificationTypeMessage:
err = hub.app.sendPushNotificationSync(
notification.post,
notification.user,
notification.channel,
notification.channelName,
notification.senderName,
notification.explicitMention,
notification.channelWideMention,
notification.replyToThreadType,
)
case notificationTypeUpdateBadge:
err = hub.app.updateMobileAppBadgeSync(notification.userID)
default:
mlog.Debug("Invalid notification type", mlog.String("notification_type", string(notification.notificationType)))
}
if err != nil {
mlog.Error("Unable to send push notification", mlog.String("notification_type", string(notification.notificationType)), mlog.Err(err))
}
}(notification)
case <-hub.stopChan:
return
}
}
}
func (hub *PushNotificationsHub) stop() {
// Drain the channel.
for i := 0; i < hub.buffer+1; i++ {
hub.notificationsChan <- PushNotification{
notificationType: notificationTypeDummy,
}
}
close(hub.stopChan)
// We need to wait for the outer for loop to exit first.
// We cannot just send struct{}{} to stopChan because there are
// other listeners to the channel. And sending just once
// will cause a race.
hub.wg.Wait()
// And then we wait for the semaphore to finish.
hub.semaWg.Wait()
}
func (s *Server) StopPushNotificationsHubWorkers() {
s.PushNotificationsHub.stop()
}
func (a *App) sendToPushProxy(msg *model.PushNotification, session *model.Session) error {
msg.ServerId = a.TelemetryId()
a.NotificationsLog().Info("Notification will be sent",
mlog.String("ackId", msg.AckId),
mlog.String("type", msg.Type),
mlog.String("userId", session.UserId),
mlog.String("postId", msg.PostId),
mlog.String("status", model.PushSendPrepare),
)
url := strings.TrimRight(*a.Config().EmailSettings.PushNotificationServer, "/") + model.APIURLSuffixV1 + "/send_push"
request, err := http.NewRequest("POST", url, strings.NewReader(msg.ToJson()))
if err != nil {
return err
}
resp, err := a.Srv().pushNotificationClient.Do(request)
if err != nil {
return err
}
defer resp.Body.Close()
pushResponse := model.PushResponseFromJson(resp.Body)
switch pushResponse[model.PushStatus] {
case model.PushStatusRemove:
a.AttachDeviceId(session.Id, "", session.ExpiresAt)
a.ClearSessionCacheForUser(session.UserId)
return errors.New("Device was reported as removed")
case model.PushStatusFail:
return errors.New(pushResponse[model.PushStatusErrorMsg])
}
return nil
}
func (a *App) SendAckToPushProxy(ack *model.PushNotificationAck) error {
if ack == nil {
return nil
}
a.NotificationsLog().Info("Notification received",
mlog.String("ackId", ack.Id),
mlog.String("type", ack.NotificationType),
mlog.String("deviceType", ack.ClientPlatform),
mlog.Int64("receivedAt", ack.ClientReceivedAt),
mlog.String("status", model.PushReceived),
)
request, err := http.NewRequest(
"POST",
strings.TrimRight(*a.Config().EmailSettings.PushNotificationServer, "/")+model.APIURLSuffixV1+"/ack",
strings.NewReader(ack.ToJson()),
)
if err != nil {
return err
}
resp, err := a.Srv().pushNotificationClient.Do(request)
if err != nil {
return err
}
defer resp.Body.Close()
// Reading the body to completion.
_, err = io.Copy(ioutil.Discard, resp.Body)
if err != nil {
return err
}
return nil
}
func (a *App) getMobileAppSessions(userID string) ([]*model.Session, *model.AppError) {
sessions, err := a.Srv().Store.Session().GetSessionsWithActiveDeviceIds(userID)
if err != nil {
return nil, model.NewAppError("getMobileAppSessions", "app.session.get_sessions.app_error", nil, err.Error(), http.StatusInternalServerError)
}
return sessions, nil
}
func ShouldSendPushNotification(user *model.User, channelNotifyProps model.StringMap, wasMentioned bool, status *model.Status, post *model.Post) bool {
return DoesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, wasMentioned) &&
DoesStatusAllowPushNotification(user.NotifyProps, status, post.ChannelId)
}
func DoesNotifyPropsAllowPushNotification(user *model.User, channelNotifyProps model.StringMap, post *model.Post, wasMentioned bool) bool {
userNotifyProps := user.NotifyProps
userNotify := userNotifyProps[model.PushNotifyProp]
channelNotify, ok := channelNotifyProps[model.PushNotifyProp]
if !ok || channelNotify == "" {
channelNotify = model.ChannelNotifyDefault
}
// If the channel is muted do not send push notifications
if channelNotifyProps[model.MarkUnreadNotifyProp] == model.ChannelMarkUnreadMention {
return false
}
if post.IsSystemMessage() {
return false
}
if channelNotify == model.UserNotifyNone {
return false
}
if channelNotify == model.ChannelNotifyMention && !wasMentioned {
return false
}
if userNotify == model.UserNotifyMention && channelNotify == model.ChannelNotifyDefault && !wasMentioned {
return false
}
if (userNotify == model.UserNotifyAll || channelNotify == model.ChannelNotifyAll) &&
(post.UserId != user.Id || post.GetProp("from_webhook") == "true") {
return true
}
if userNotify == model.UserNotifyNone &&
channelNotify == model.ChannelNotifyDefault {
return false
}
return true
}
func DoesStatusAllowPushNotification(userNotifyProps model.StringMap, status *model.Status, channelID string) bool {
// If User status is DND or OOO return false right away
if status.Status == model.StatusDnd || status.Status == model.StatusOutOfOffice {
return false
}
pushStatus, ok := userNotifyProps[model.PushStatusNotifyProp]
if (pushStatus == model.StatusOnline || !ok) && (status.ActiveChannel != channelID || model.GetMillis()-status.LastActivityAt > model.StatusChannelTimeout) {
return true
}
if pushStatus == model.StatusAway && (status.Status == model.StatusAway || status.Status == model.StatusOffline) {
return true
}
if pushStatus == model.StatusOffline && status.Status == model.StatusOffline {
return true
}
return false
}
func (a *App) BuildPushNotificationMessage(contentsConfig string, post *model.Post, user *model.User, channel *model.Channel, channelName string, senderName string,
explicitMention bool, channelWideMention bool, replyToThreadType string) (*model.PushNotification, *model.AppError) {
var msg *model.PushNotification
notificationInterface := a.Srv().Notification
if (notificationInterface == nil || notificationInterface.CheckLicense() != nil) && contentsConfig == model.IdLoadedNotification {
contentsConfig = model.GenericNotification
}
if contentsConfig == model.IdLoadedNotification {
msg = a.buildIdLoadedPushNotificationMessage(post, user)
} else {
msg = a.buildFullPushNotificationMessage(contentsConfig, post, user, channel, channelName, senderName, explicitMention, channelWideMention, replyToThreadType)
}
unreadCount, err := a.Srv().Store.User().GetUnreadCount(user.Id)
if err != nil {
return nil, model.NewAppError("BuildPushNotificationMessage", "app.user.get_unread_count.app_error", nil, err.Error(), http.StatusInternalServerError)
}
msg.Badge = int(unreadCount)
return msg, nil
}
func (a *App) buildIdLoadedPushNotificationMessage(post *model.Post, user *model.User) *model.PushNotification {
userLocale := i18n.GetUserTranslations(user.Locale)
msg := &model.PushNotification{
PostId: post.Id,
ChannelId: post.ChannelId,
Category: model.CategoryCanReply,
Version: model.PushMessageV2,
Type: model.PushTypeMessage,
IsIdLoaded: true,
SenderId: user.Id,
Message: userLocale("api.push_notification.id_loaded.default_message"),
}
return msg
}
func (a *App) buildFullPushNotificationMessage(contentsConfig string, post *model.Post, user *model.User, channel *model.Channel, channelName string, senderName string,
explicitMention bool, channelWideMention bool, replyToThreadType string) *model.PushNotification {
msg := &model.PushNotification{
Category: model.CategoryCanReply,
Version: model.PushMessageV2,
Type: model.PushTypeMessage,
TeamId: channel.TeamId,
ChannelId: channel.Id,
PostId: post.Id,
RootId: post.RootId,
SenderId: post.UserId,
IsIdLoaded: false,
}
userLocale := i18n.GetUserTranslations(user.Locale)
cfg := a.Config()
if contentsConfig != model.GenericNoChannelNotification || channel.Type == model.ChannelTypeDirect {
msg.ChannelName = channelName
if a.isCRTEnabledForUser(user.Id) && post.RootId != "" {
msg.ChannelName = userLocale("api.push_notification.title.collapsed_threads")
}
}
msg.SenderName = senderName
if ou, ok := post.GetProp("override_username").(string); ok && *cfg.ServiceSettings.EnablePostUsernameOverride {
msg.OverrideUsername = ou
msg.SenderName = ou
}
if oi, ok := post.GetProp("override_icon_url").(string); ok && *cfg.ServiceSettings.EnablePostIconOverride {
msg.OverrideIconURL = oi
}
if fw, ok := post.GetProp("from_webhook").(string); ok {
msg.FromWebhook = fw
}
postMessage := post.Message
stripped, err := utils.StripMarkdown(postMessage)
if err != nil {
mlog.Warn("Failed parse to markdown", mlog.String("post_id", post.Id), mlog.Err(err))
} else {
postMessage = stripped
}
for _, attachment := range post.Attachments() {
if attachment.Fallback != "" {
postMessage += "\n" + attachment.Fallback
}
}
hasFiles := post.FileIds != nil && len(post.FileIds) > 0
msg.Message = a.getPushNotificationMessage(
contentsConfig,
postMessage,
explicitMention,
channelWideMention,
hasFiles,
msg.SenderName,
channel.Type,
replyToThreadType,
userLocale,
)
return msg
}