Files
mostlymatter/app/notification_push.go
Agniva De Sarker ab8de49f0a MM-40818: Refactor product initialization to happen late (#19658)
This is more of a general refactor of the initialization
process which should allow us to pass services more
easily.

The changes are minimal to keep the scope limited.
For now, the objective is to pass the file service
to the Channels product. For that, it was required
to move some of the enterprise interfaces under Channels
from Server.

We also create a filestore field in the server to
avoid creating filestore reference every time
we make a filestore operation. This will be later
passed on to the Channels product.

Also removed an unnecessary test. 
The test was working so far because we were creating
the filebackend every time for every request. But
we should go via UpdateConfig call which would fail,
were we to assign an invalid filestore name.

So we were actually testing for a different thing.
Therefore, removed the test.

```release-note
NONE
```
2022-03-03 12:22:10 +05:30

712 строки
22 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"encoding/json"
"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
rootID 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 && replyToThreadType != model.CommentsNotifyCRT {
return model.ClearMentionTags(postMessage)
}
return senderName + ": " + model.ClearMentionTags(postMessage)
}
if channelType == model.ChannelTypeDirect {
if replyToThreadType == model.CommentsNotifyCRT {
if contentsConfig == model.GenericNoChannelNotification {
return senderName + userLocale("api.post.send_notification_and_forget.push_comment_on_crt_thread")
}
return senderName + userLocale("api.post.send_notification_and_forget.push_comment_on_crt_thread_dm")
}
return userLocale("api.post.send_notifications_and_forget.push_message")
}
if replyToThreadType == model.CommentsNotifyCRT {
return senderName + userLocale("api.post.send_notification_and_forget.push_comment_on_crt_thread")
}
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, rootID string) *model.AppError {
msg := &model.PushNotification{
Type: model.PushTypeClear,
Version: model.PushMessageV2,
ChannelId: channelID,
RootId: rootID,
ContentAvailable: 1,
Badge: 0,
IsCRTEnabled: a.IsCRTEnabledForUser(userID),
}
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)
if msg.IsCRTEnabled {
data, err := a.Srv().Store.Thread().GetThreadsForUser(userID, "", model.GetUserThreadsOpts{TotalsOnly: true})
if err != nil {
return model.NewAppError("clearPushNotificationSync", "app.user.get_thread_count_for_user.app_error", nil, err.Error(), http.StatusInternalServerError)
}
msg.Badge += int(data.TotalUnreadMentions)
}
return a.sendPushNotificationToAllSessions(msg, userID, currentSessionId)
}
func (a *App) clearPushNotification(currentSessionId, userID, channelID, rootID string) {
select {
case a.Srv().PushNotificationsHub.notificationsChan <- PushNotification{
notificationType: notificationTypeClear,
currentSessionId: currentSessionId,
userID: userID,
channelID: channelID,
rootID: rootID,
}:
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.Channels())),
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, notification.rootID)
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) rawSendToPushProxy(msg *model.PushNotification) (model.PushResponse, error) {
msgJSON, jsonErr := json.Marshal(msg)
if jsonErr != nil {
return nil, errors.Wrap(jsonErr, "failed to encode to JSON")
}
url := strings.TrimRight(*a.Config().EmailSettings.PushNotificationServer, "/") + model.APIURLSuffixV1 + "/send_push"
request, err := http.NewRequest("POST", url, bytes.NewReader(msgJSON))
if err != nil {
return nil, err
}
resp, err := a.Srv().pushNotificationClient.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var pushResponse model.PushResponse
if jsonErr := json.NewDecoder(resp.Body).Decode(&pushResponse); jsonErr != nil {
return nil, errors.Wrap(jsonErr, "failed to decode from JSON")
}
return pushResponse, nil
}
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),
)
pushResponse, err := a.rawSendToPushProxy(msg)
if err != nil {
return err
}
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),
)
ackJSON, jsonErr := json.Marshal(ack)
if jsonErr != nil {
return errors.Wrap(jsonErr, "failed to encode to JSON")
}
request, err := http.NewRequest(
"POST",
strings.TrimRight(*a.Config().EmailSettings.PushNotificationServer, "/")+model.APIURLSuffixV1+"/ack",
bytes.NewReader(ackJSON),
)
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.ch.Notification
if (notificationInterface == nil || notificationInterface.CheckLicense() != nil) && contentsConfig == model.IdLoadedNotification {
contentsConfig = model.GenericNotification
}
if contentsConfig == model.IdLoadedNotification {
msg = a.buildIdLoadedPushNotificationMessage(channel, 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) SendTestPushNotification(deviceID string) string {
msg := &model.PushNotification{
Version: "2",
Type: model.PushTypeTest,
ServerId: a.TelemetryId(),
Badge: -1,
}
msg.SetDeviceIdAndPlatform(deviceID)
pushResponse, err := a.rawSendToPushProxy(msg)
if err != nil {
a.NotificationsLog().Error("Notification error",
mlog.String("type", msg.Type),
mlog.String("deviceId", msg.DeviceId),
mlog.String("status", err.Error()),
)
return "unknown"
}
switch pushResponse[model.PushStatus] {
case model.PushStatusRemove:
return "false"
case model.PushStatusFail:
a.NotificationsLog().Error("Notification error",
mlog.String("type", msg.Type),
mlog.String("deviceId", msg.DeviceId),
mlog.String("status", pushResponse[model.PushStatusErrorMsg]),
)
return "unknown"
}
return "true"
}
func (a *App) buildIdLoadedPushNotificationMessage(channel *model.Channel, post *model.Post, user *model.User) *model.PushNotification {
userLocale := i18n.GetUserTranslations(user.Locale)
msg := &model.PushNotification{
PostId: post.Id,
ChannelId: post.ChannelId,
RootId: post.RootId,
IsCRTEnabled: a.IsCRTEnabledForUser(user.Id),
Category: model.CategoryCanReply,
Version: model.PushMessageV2,
TeamId: channel.TeamId,
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,
IsCRTEnabled: false,
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) {
msg.IsCRTEnabled = true
if post.RootId != "" {
if contentsConfig != model.GenericNoChannelNotification {
props := map[string]interface{}{"channelName": channelName}
msg.ChannelName = userLocale("api.push_notification.title.collapsed_threads", props)
if channel.Type == model.ChannelTypeDirect {
msg.ChannelName = userLocale("api.push_notification.title.collapsed_threads_dm")
}
}
}
}
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
}