mattermost-community-enterp.../channels/app/post_persistent_notification.go
Claude ec1f89217a Merge: Complete Mattermost Server with Community Enterprise
Full Mattermost server source with integrated Community Enterprise features.
Includes vendor directory for offline/air-gapped builds.

Structure:
- enterprise-impl/: Enterprise feature implementations
- enterprise-community/: Init files that register implementations
- enterprise/: Bridge imports (community_imports.go)
- vendor/: All dependencies for offline builds

Build (online):
  go build ./cmd/mattermost

Build (offline/air-gapped):
  go build -mod=vendor ./cmd/mattermost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 23:59:07 +09:00

394 lines
14 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"maps"
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/pkg/errors"
)
// ResolvePersistentNotification stops the persistent notifications, if a loggedInUserID(except the post owner) reacts, reply or ack on the post.
// Post-owner can only delete the original post to stop the notifications.
func (a *App) ResolvePersistentNotification(rctx request.CTX, post *model.Post, loggedInUserID string) *model.AppError {
// Ignore the post owner's actions to their own post
if loggedInUserID == post.UserId {
return nil
}
if !a.IsPersistentNotificationsEnabled() {
return nil
}
_, err := a.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
// Either the notification post is already deleted or was never a notification post
return nil
default:
return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if !*a.Config().ServiceSettings.AllowPersistentNotificationsForGuests {
user, nErr := a.Srv().Store().User().Get(context.Background(), loggedInUserID)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return model.NewAppError("ResolvePersistentNotification", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr)
default:
return model.NewAppError("ResolvePersistentNotification", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if user.IsGuest() {
return nil
}
}
stopNotifications := false
if err := a.forEachPersistentNotificationPost([]*model.Post{post}, func(_ *model.Post, _ *model.Channel, _ *model.Team, mentions *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error {
if mentions.isUserMentioned(loggedInUserID) {
stopNotifications = true
}
return nil
}); err != nil {
return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Only mentioned users can stop the notifications
if !stopNotifications {
return nil
}
if err := a.Srv().Store().PostPersistentNotification().Delete([]string{post.Id}); err != nil {
return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// DeletePersistentNotification stops the persistent notifications.
func (a *App) DeletePersistentNotification(rctx request.CTX, post *model.Post) *model.AppError {
if !a.IsPersistentNotificationsEnabled() {
return nil
}
_, err := a.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
// Either the notification post is already deleted or was never a notification post
return nil
default:
return model.NewAppError("DeletePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if err := a.Srv().Store().PostPersistentNotification().Delete([]string{post.Id}); err != nil {
return model.NewAppError("DeletePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) SendPersistentNotifications() error {
notificationInterval := time.Duration(*a.Config().ServiceSettings.PersistentNotificationIntervalMinutes) * time.Minute
notificationMaxCount := int16(*a.Config().ServiceSettings.PersistentNotificationMaxCount)
// fetch posts for which the "notificationInterval duration" has passed
maxTime := time.Now().Add(-notificationInterval).UnixMilli()
// Pagination loop
for {
notificationPosts, err := a.Srv().Store().PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{
MaxTime: maxTime,
MaxSentCount: notificationMaxCount,
PerPage: 500,
})
if err != nil {
return errors.Wrap(err, "failed to get posts for persistent notifications")
}
// No posts left to send persistent notifications
if len(notificationPosts) == 0 {
break
}
postIds := make([]string, 0, len(notificationPosts))
for _, p := range notificationPosts {
postIds = append(postIds, p.PostId)
}
posts, err := a.Srv().Store().Post().GetPostsByIds(postIds)
if err != nil {
return errors.Wrap(err, "failed to get posts by IDs")
}
// Send notifications
if err := a.forEachPersistentNotificationPost(posts, a.sendPersistentNotifications); err != nil {
return err
}
if err := a.Srv().Store().PostPersistentNotification().UpdateLastActivity(postIds); err != nil {
return errors.Wrapf(err, "failed to update lastActivity for notifications: %v", postIds)
}
}
if err := a.Srv().Store().PostPersistentNotification().DeleteExpired(notificationMaxCount); err != nil {
return errors.Wrap(err, "failed to delete expired notifications")
}
return nil
}
func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(post *model.Post, channel *model.Channel, team *model.Team, mentions *MentionResults, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error) error {
channelsMap, teamsMap, err := a.channelTeamMapsForPosts(posts)
if err != nil {
return err
}
channelGroupMap, channelProfileMap, channelKeywords, channelNotifyProps, err := a.persistentNotificationsAuxiliaryData(channelsMap, teamsMap)
if err != nil {
return err
}
for _, post := range posts {
channel := channelsMap[post.ChannelId]
team := teamsMap[channel.TeamId]
// GMs and DMs don't belong to any team
if channel.IsGroupOrDirect() {
team = &model.Team{}
}
profileMap := channelProfileMap[channel.Id]
mentions := &MentionResults{}
// In DMs, only the "other" user can be mentioned
if channel.Type == model.ChannelTypeDirect {
otherUserId := channel.GetOtherUserIdForDM(post.UserId)
if _, ok := profileMap[otherUserId]; ok {
mentions.addMention(otherUserId, DMMention)
}
} else {
keywords := channelKeywords[channel.Id]
keywords.AddGroupsMap(channelGroupMap[channel.Id])
mentions = getExplicitMentions(post, keywords)
for groupID := range mentions.GroupMentions {
group := channelGroupMap[channel.Id][groupID]
_, err := a.insertGroupMentions(post.UserId, group, channel, profileMap, mentions)
if err != nil {
return errors.Wrapf(err, "failed to include mentions from group - %s for channel - %s", group.Id, channel.Id)
}
}
}
if err := fn(post, channel, team, mentions, profileMap, channelNotifyProps); err != nil {
return err
}
}
return nil
}
func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model.Channel, teamsMap map[string]*model.Team) (map[string]map[string]*model.Group, map[string]model.UserMap, map[string]MentionKeywords, map[string]map[string]model.StringMap, error) {
channelGroupMap := make(map[string]map[string]*model.Group, len(channelsMap))
channelProfileMap := make(map[string]model.UserMap, len(channelsMap))
channelKeywords := make(map[string]MentionKeywords, len(channelsMap))
channelNotifyProps := make(map[string]map[string]model.StringMap, len(channelsMap))
for _, c := range channelsMap {
// In DM, notifications can't be send to any 3rd person.
if c.Type != model.ChannelTypeDirect {
groups, err := a.getGroupsAllowedForReferenceInChannel(c, teamsMap[c.TeamId])
if err != nil {
return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id)
}
channelGroupMap[c.Id] = make(map[string]*model.Group, len(groups))
maps.Copy(channelGroupMap[c.Id], groups)
props, err := a.Srv().Store().Channel().GetAllChannelMembersNotifyPropsForChannel(c.Id, true)
if err != nil {
return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id)
}
channelNotifyProps[c.Id] = props
}
profileMap, err := a.Srv().Store().User().GetAllProfilesInChannel(context.Background(), c.Id, true)
if err != nil {
return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id)
}
channelKeywords[c.Id] = make(MentionKeywords, len(profileMap))
validProfileMap := make(map[string]*model.User, len(profileMap))
for userID, user := range profileMap {
if user.IsBot {
continue
}
validProfileMap[userID] = user
channelKeywords[c.Id].AddUserKeyword(userID, "@"+user.Username)
}
channelProfileMap[c.Id] = validProfileMap
}
return channelGroupMap, channelProfileMap, channelKeywords, channelNotifyProps, nil
}
func (a *App) channelTeamMapsForPosts(posts []*model.Post) (map[string]*model.Channel, map[string]*model.Team, error) {
channelIds := make(model.StringSet)
for _, p := range posts {
channelIds.Add(p.ChannelId)
}
channels, err := a.Srv().Store().Channel().GetChannelsByIds(channelIds.Val(), false)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to get teams by IDs")
}
channelsMap := make(map[string]*model.Channel, len(channels))
for _, c := range channels {
channelsMap[c.Id] = c
}
teamIds := make(model.StringSet)
for _, c := range channels {
if c.TeamId != "" {
teamIds.Add(c.TeamId)
}
}
teams := make([]*model.Team, 0, len(teamIds))
if len(teamIds) > 0 {
teams, err = a.Srv().Store().Team().GetMany(teamIds.Val())
if err != nil {
return nil, nil, errors.Wrap(err, "failed to get teams by IDs")
}
}
teamsMap := make(map[string]*model.Team, len(teams))
for _, t := range teams {
teamsMap[t.Id] = t
}
return channelsMap, teamsMap, nil
}
func (a *App) sendPersistentNotifications(post *model.Post, channel *model.Channel, team *model.Team, mentions *MentionResults, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error {
mentionedUsersList := make(model.StringArray, 0, len(mentions.Mentions))
for id, v := range mentions.Mentions {
// Don't send notification to post owner nor GM mentions
if id != post.UserId && v > GMMention {
mentionedUsersList = append(mentionedUsersList, id)
}
}
sender := profileMap[post.UserId]
notification := &PostNotification{
Post: post,
Channel: channel,
ProfileMap: profileMap,
Sender: sender,
}
if int64(len(mentionedUsersList)) > *a.Config().TeamSettings.MaxNotificationsPerChannel {
return errors.Errorf("mentioned users: %d are more than allowed users: %d", len(mentionedUsersList), *a.Config().TeamSettings.MaxNotificationsPerChannel)
}
if a.canSendPushNotifications() {
for _, userID := range mentionedUsersList {
user := profileMap[userID]
if user == nil {
continue
}
rctx := request.EmptyContext(a.Log().With(
mlog.String("receiver_id", userID),
))
status, err := a.GetStatus(userID)
if err != nil {
mlog.Warn("Unable to fetch online status", mlog.Err(err))
status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
isGM := channel.Type == model.ChannelTypeGroup
if a.ShouldSendPushNotification(rctx, profileMap[userID], channelNotifyProps[channel.Id][userID], true, status, post, isGM) {
a.sendPushNotification(
notification,
user,
true,
false,
"",
)
}
}
}
desktopUsers := make([]string, 0, len(mentionedUsersList))
for _, id := range mentionedUsersList {
user := profileMap[id]
if user == nil {
continue
}
if user.NotifyProps[model.DesktopNotifyProp] != model.UserNotifyNone && a.persistentNotificationsAllowedForStatus(id) {
desktopUsers = append(desktopUsers, id)
}
}
if len(desktopUsers) != 0 {
post = a.PreparePostForClient(request.EmptyContext(a.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
postJSON, jsonErr := post.ToJSON()
if jsonErr != nil {
return errors.Wrapf(jsonErr, "failed to encode post to JSON")
}
for _, u := range desktopUsers {
message := model.NewWebSocketEvent(model.WebsocketEventPersistentNotificationTriggered, team.Id, post.ChannelId, u, nil, "")
message.Add("post", postJSON)
message.Add("channel_type", channel.Type)
message.Add("channel_display_name", notification.GetChannelName(model.ShowUsername, ""))
message.Add("channel_name", channel.Name)
message.Add("sender_name", notification.GetSenderName(model.ShowUsername, *a.Config().ServiceSettings.EnablePostUsernameOverride))
message.Add("team_id", team.Id)
if len(post.FileIds) != 0 {
message.Add("otherFile", "true")
infos, err := a.Srv().Store().FileInfo().GetForPost(post.Id, false, false, true)
if err != nil {
mlog.Warn("Unable to get fileInfo for push notifications.", mlog.String("post_id", post.Id), mlog.Err(err))
}
for _, info := range infos {
if info.IsImage() {
message.Add("image", "true")
break
}
}
}
message.Add("mentions", model.ArrayToJSON(desktopUsers))
a.Publish(message)
}
}
return nil
}
func (a *App) persistentNotificationsAllowedForStatus(userID string) bool {
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(userID); err != nil {
status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
return status.Status != model.StatusDnd && status.Status != model.StatusOutOfOffice
}
func (a *App) IsPersistentNotificationsEnabled() bool {
return a.IsPostPriorityEnabled() && *a.Config().ServiceSettings.AllowPersistentNotifications
}