mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
593 lines
24 KiB
Go
593 lines
24 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/shared/i18n"
|
|
"github.com/hanzoteam/server/server/public/shared/mlog"
|
|
"github.com/hanzoteam/server/server/public/shared/request"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
getPendingScheduledPostsPageSize = 100
|
|
scheduledPostBatchWaitTime = 100 * time.Millisecond
|
|
)
|
|
|
|
func (a *App) ProcessScheduledPosts(rctx request.CTX) {
|
|
rctx = rctx.WithLogFields(mlog.String("component", "scheduled_post_job"))
|
|
|
|
// Intentionally not gated on FeatureFlags.RecurringScheduledPosts: existing recurring rows
|
|
// keep sending and advancing even after the flag is turned off.
|
|
if !*a.Config().ServiceSettings.ScheduledPosts {
|
|
return
|
|
}
|
|
|
|
if a.License() == nil {
|
|
return
|
|
}
|
|
|
|
beforeTime := model.GetMillis()
|
|
afterTime := beforeTime - (24 * 60 * 60 * 1000) // subtracting 24 hours from beforeTime
|
|
lastScheduledPostId := ""
|
|
|
|
for {
|
|
// we wait some time before processing each batch to avoid hammering the database with too many requests.
|
|
time.Sleep(scheduledPostBatchWaitTime)
|
|
|
|
scheduledPostsBatch, err := a.Srv().Store().ScheduledPost().GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, getPendingScheduledPostsPageSize)
|
|
if err != nil {
|
|
rctx.Logger().Error(
|
|
"App.ProcessScheduledPosts: failed to fetch pending scheduled posts page from database",
|
|
mlog.Int("before_time", beforeTime),
|
|
mlog.String("last_scheduled_post_id", lastScheduledPostId),
|
|
mlog.Int("items_per_page", getPendingScheduledPostsPageSize),
|
|
mlog.Err(err),
|
|
)
|
|
|
|
// Break the loop if we can't fetch the page.
|
|
// Missed posts will be processed in job's next round.
|
|
// Since we don't know any item's details, we can't fetch the next page as well.
|
|
// We could retry here but that's the same as trying in job's next round.
|
|
break
|
|
}
|
|
|
|
if len(scheduledPostsBatch) == 0 {
|
|
// break loop if there are no more scheduled posts
|
|
break
|
|
}
|
|
|
|
// Saving the last item to use as marker for next page
|
|
lastScheduledPostId = scheduledPostsBatch[len(scheduledPostsBatch)-1].Id
|
|
beforeTime = scheduledPostsBatch[len(scheduledPostsBatch)-1].ScheduledAt
|
|
|
|
if err := a.processScheduledPostBatch(rctx, scheduledPostsBatch); err != nil {
|
|
rctx.Logger().Error(
|
|
"App.ProcessScheduledPosts: failed to process scheduled posts batch",
|
|
mlog.Int("before_time", beforeTime),
|
|
mlog.String("last_scheduled_post_id", lastScheduledPostId),
|
|
mlog.Int("items_per_page", getPendingScheduledPostsPageSize),
|
|
mlog.Err(err),
|
|
)
|
|
|
|
// failure to process one batch doesn't mean other batches will fail as well.
|
|
// Continue processing next batch. The posts that failed in this batch will be picked
|
|
// up when the job next runs.
|
|
continue
|
|
}
|
|
|
|
if len(scheduledPostsBatch) < getPendingScheduledPostsPageSize {
|
|
// if we got less than page size worth of scheduled posts, it indicates
|
|
// that we have no more pending scheduled posts. So, we can break instead of making
|
|
// an additional database call as we know there are going to be no records in there.
|
|
break
|
|
}
|
|
}
|
|
|
|
// once all scheduled posts are processed, we need to update and close the old ones
|
|
// as we don't process pending scheduled posts more than 24 hours old.
|
|
if err := a.Srv().Store().ScheduledPost().UpdateOldScheduledPosts(afterTime); err != nil {
|
|
rctx.Logger().Error(
|
|
"App.ProcessScheduledPosts: failed to update old scheduled posts",
|
|
mlog.Int("cutoff_time", afterTime),
|
|
mlog.Err(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
// scheduledPostDisposition is the explicit outcome of attempting to send one scheduled post,
|
|
// so the batch loop never has to infer it from error/error-code combinations.
|
|
type scheduledPostDisposition int
|
|
|
|
const (
|
|
// scheduledPostPosted: the message was created; one-shots are done and series advance.
|
|
scheduledPostPosted scheduledPostDisposition = iota
|
|
|
|
// scheduledPostFailed: the attempt failed; the post keeps its error code so the user can
|
|
// see and fix it, and any series stops until rescheduled.
|
|
scheduledPostFailed
|
|
|
|
// scheduledPostUnsendable: the destination channel no longer exists, so the post — and any
|
|
// series — can never send and is permanently deleted.
|
|
scheduledPostUnsendable
|
|
)
|
|
|
|
// processScheduledPostBatch processes one batch
|
|
func (a *App) processScheduledPostBatch(rctx request.CTX, scheduledPosts []*model.ScheduledPost) error {
|
|
var failedScheduledPosts []*model.ScheduledPost
|
|
var completedScheduledPosts []*model.ScheduledPost
|
|
var recurringScheduledPosts []*model.ScheduledPost
|
|
now := model.GetMillis()
|
|
|
|
for i := range scheduledPosts {
|
|
scheduledPost := scheduledPosts[i]
|
|
|
|
switch disposition, err := a.postScheduledPost(rctx, scheduledPost); disposition {
|
|
case scheduledPostFailed:
|
|
rctx.Logger().Error("processScheduledPostBatch scheduled post processing failed", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.Err(err))
|
|
failedScheduledPosts = append(failedScheduledPosts, scheduledPost)
|
|
continue
|
|
case scheduledPostUnsendable:
|
|
completedScheduledPosts = append(completedScheduledPosts, scheduledPost)
|
|
continue
|
|
case scheduledPostPosted:
|
|
default:
|
|
// An unhandled disposition is a bug; never delete on a guess. Fail the post so the
|
|
// user is told and the row survives.
|
|
rctx.Logger().Error("processScheduledPostBatch unhandled scheduled post disposition", mlog.Int("disposition", int(disposition)), mlog.String("scheduled_post_id", scheduledPost.Id))
|
|
scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError
|
|
failedScheduledPosts = append(failedScheduledPosts, scheduledPost)
|
|
continue
|
|
}
|
|
|
|
if !scheduledPost.IsRecurring() {
|
|
completedScheduledPosts = append(completedScheduledPosts, scheduledPost)
|
|
continue
|
|
}
|
|
|
|
nextScheduledAt, err := scheduledPost.ComputeNextScheduledAt(now)
|
|
if err != nil {
|
|
// Fail the series loudly rather than leaving it due, which would repost it on every job run.
|
|
rctx.Logger().Error("processScheduledPostBatch failed to compute next occurrence of recurring scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.Err(err))
|
|
scheduledPost.ErrorCode = model.ScheduledPostErrorUnableToSend
|
|
failedScheduledPosts = append(failedScheduledPosts, scheduledPost)
|
|
continue
|
|
}
|
|
|
|
// The store resets these columns too; mirroring it here keeps the WS payload consistent.
|
|
scheduledPost.ScheduledAt = nextScheduledAt
|
|
scheduledPost.ErrorCode = ""
|
|
scheduledPost.ProcessedAt = 0
|
|
recurringScheduledPosts = append(recurringScheduledPosts, scheduledPost)
|
|
}
|
|
|
|
if err := a.handleSuccessfulScheduledPosts(rctx, completedScheduledPosts, recurringScheduledPosts); err != nil {
|
|
return errors.Wrap(err, "App.processScheduledPostBatch: failed to handle successfully posted scheduled posts")
|
|
}
|
|
|
|
a.handleFailedScheduledPosts(rctx, failedScheduledPosts)
|
|
return nil
|
|
}
|
|
|
|
// postScheduledPost attempts to send an individual scheduled post and returns an explicit
|
|
// disposition. Failed dispositions set the post's ErrorCode for persistence and notification.
|
|
func (a *App) postScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (scheduledPostDisposition, error) {
|
|
// we'll process scheduled posts one by one.
|
|
// If an error occurs, we'll log it and move onto the next scheduled post
|
|
|
|
channel, appErr := a.GetChannel(rctx, scheduledPost.ChannelId)
|
|
if appErr != nil {
|
|
if appErr.StatusCode == http.StatusNotFound {
|
|
rctx.Logger().Warn("channel for scheduled post not found, setting error code", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("channel_id", scheduledPost.ChannelId), mlog.String("error_code", model.ScheduledPostErrorCodeChannelNotFound), mlog.Err(appErr))
|
|
|
|
scheduledPost.ErrorCode = model.ScheduledPostErrorCodeChannelNotFound
|
|
return scheduledPostUnsendable, nil
|
|
}
|
|
|
|
rctx.Logger().Error(
|
|
"App.processScheduledPostBatch: failed to get channel for scheduled post",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.String("error_code", model.ScheduledPostErrorUnknownError),
|
|
mlog.Err(appErr),
|
|
)
|
|
|
|
scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError
|
|
return scheduledPostFailed, appErr
|
|
}
|
|
|
|
errorCode, err := a.canPostScheduledPost(rctx, scheduledPost, channel)
|
|
scheduledPost.ErrorCode = errorCode
|
|
if err != nil {
|
|
rctx.Logger().Error(
|
|
"App.processScheduledPostBatch: failed to check if scheduled post can be posted",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("user_id", scheduledPost.UserId),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.Err(err),
|
|
)
|
|
|
|
return scheduledPostFailed, err
|
|
}
|
|
|
|
if scheduledPost.ErrorCode != "" {
|
|
rctx.Logger().Warn(
|
|
"App.processScheduledPostBatch: skipping posting a scheduled post as `can post` check failed",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("user_id", scheduledPost.UserId),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.String("error_code", scheduledPost.ErrorCode),
|
|
)
|
|
|
|
return scheduledPostFailed, fmt.Errorf("App.processScheduledPostBatch: skipping posting a scheduled post as `can post` check failed, error_code: %s", scheduledPost.ErrorCode)
|
|
}
|
|
|
|
post, err := scheduledPost.ToPost()
|
|
if err != nil {
|
|
rctx.Logger().Error(
|
|
"App.processScheduledPostBatch: failed to convert scheduled post to a post",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("error_code", model.ScheduledPostErrorUnknownError),
|
|
mlog.Err(err),
|
|
)
|
|
|
|
scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError
|
|
return scheduledPostFailed, err
|
|
}
|
|
|
|
_, _, appErr = a.CreatePost(rctx.WithContext(context.WithValue(rctx.Context(), model.PostContextKeyIsScheduledPost, true)), post, channel, model.CreatePostFlags{
|
|
TriggerWebhooks: true,
|
|
SetOnline: false,
|
|
})
|
|
if appErr != nil {
|
|
rctx.Logger().Error(
|
|
"App.processScheduledPostBatch: failed to post scheduled post",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.String("error_code", model.ScheduledPostErrorUnknownError),
|
|
mlog.Err(appErr),
|
|
)
|
|
|
|
scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError
|
|
return scheduledPostFailed, appErr
|
|
}
|
|
|
|
return scheduledPostPosted, nil
|
|
}
|
|
|
|
// canPostScheduledPost checks whether the scheduled post be created based on permissions and other checks.
|
|
func (a *App) canPostScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost, channel *model.Channel) (string, error) {
|
|
user, appErr := a.GetUser(scheduledPost.UserId)
|
|
if appErr != nil {
|
|
if appErr.Id == MissingAccountError {
|
|
rctx.Logger().Debug("canPostScheduledPost user not found for scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("user_id", scheduledPost.UserId), mlog.String("error_code", model.ScheduledPostErrorCodeUserDoesNotExist))
|
|
return model.ScheduledPostErrorCodeUserDoesNotExist, nil
|
|
}
|
|
|
|
rctx.Logger().Error(
|
|
"App.canPostScheduledPost: failed to get user from database",
|
|
mlog.String("user_id", scheduledPost.UserId),
|
|
mlog.String("error_code", model.ScheduledPostErrorUnknownError),
|
|
mlog.Err(appErr),
|
|
)
|
|
return model.ScheduledPostErrorUnknownError, errors.Wrapf(appErr, "App.canPostScheduledPost: failed to get user from database, userId: %s", scheduledPost.UserId)
|
|
}
|
|
|
|
if user.DeleteAt != 0 {
|
|
rctx.Logger().Debug("canPostScheduledPost user for scheduled posts is deleted", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("user_id", scheduledPost.UserId), mlog.String("error_code", model.ScheduledPostErrorCodeUserDeleted))
|
|
return model.ScheduledPostErrorCodeUserDeleted, nil
|
|
}
|
|
|
|
if channel.DeleteAt != 0 {
|
|
rctx.Logger().Debug("canPostScheduledPost channel for scheduled post is archived", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("channel_id", channel.Id), mlog.String("error_code", model.ScheduledPostErrorCodeChannelArchived))
|
|
return model.ScheduledPostErrorCodeChannelArchived, nil
|
|
}
|
|
|
|
restrictDM, err := a.CheckIfChannelIsRestrictedDM(rctx, channel)
|
|
if err != nil {
|
|
rctx.Logger().Debug("canPostScheduledPost unknown error fetching teams for restricted DM", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("channel_id", channel.Id), mlog.String("error_code", model.ScheduledPostErrorUnknownError))
|
|
return model.ScheduledPostErrorUnknownError, err
|
|
}
|
|
|
|
if restrictDM {
|
|
rctx.Logger().Debug("canPostScheduledPost channel for scheduled post is restricted DM", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("channel_id", channel.Id), mlog.String("error_code", model.ScheduledPostErrorCodeRestrictedDM))
|
|
return model.ScheduledPostErrorCodeRestrictedDM, err
|
|
}
|
|
|
|
if scheduledPost.RootId != "" {
|
|
rootPosts, _, appErr := a.GetPostsByIds([]string{scheduledPost.RootId})
|
|
if appErr != nil {
|
|
if appErr.StatusCode == http.StatusNotFound {
|
|
rctx.Logger().Debug("canPostScheduledPost thread root post for scheduled post is missing", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("root_post_id", scheduledPost.RootId), mlog.String("error_code", model.ScheduledPostErrorThreadDeleted))
|
|
return model.ScheduledPostErrorThreadDeleted, nil
|
|
}
|
|
|
|
rctx.Logger().Error(
|
|
"App.canPostScheduledPost: failed to get root post",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("root_post_id", scheduledPost.RootId),
|
|
mlog.String("error_code", model.ScheduledPostErrorUnknownError),
|
|
mlog.Err(appErr),
|
|
)
|
|
|
|
return model.ScheduledPostErrorUnknownError, errors.Wrapf(appErr, "App.canPostScheduledPost: failed to get root post, scheduled_post_id: %s, root_post_id: %s", scheduledPost.Id, scheduledPost.RootId)
|
|
}
|
|
|
|
// you do get deleted posts from `GetPostsByIds`, so need to validate that as well
|
|
if len(rootPosts) == 1 && rootPosts[0].Id == scheduledPost.RootId && rootPosts[0].DeleteAt != 0 {
|
|
rctx.Logger().Debug("canPostScheduledPost thread root post is deleted", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("root_post_id", scheduledPost.RootId), mlog.String("error_code", model.ScheduledPostErrorThreadDeleted))
|
|
return model.ScheduledPostErrorThreadDeleted, nil
|
|
}
|
|
}
|
|
|
|
if appErr := userCreatePostPermissionCheckWithApp(rctx, a, scheduledPost.UserId, scheduledPost.ChannelId); appErr != nil {
|
|
rctx.Logger().Debug(
|
|
"canPostScheduledPost user does not have permission to create post in channel",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("user_id", scheduledPost.UserId),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.String("error_code", model.ScheduledPostErrorCodeNoChannelPermission),
|
|
mlog.Err(appErr),
|
|
)
|
|
return model.ScheduledPostErrorCodeNoChannelPermission, nil
|
|
}
|
|
|
|
if appErr := PostHardenedModeCheckWithApp(a, false, scheduledPost.GetProps()); appErr != nil {
|
|
rctx.Logger().Debug(
|
|
"canPostScheduledPost hardened mode enabled: post contains props prohibited in hardened mode",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("user_id", scheduledPost.UserId),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.String("error_code", model.ScheduledPostErrorInvalidPost),
|
|
mlog.Err(appErr),
|
|
)
|
|
return model.ScheduledPostErrorInvalidPost, nil
|
|
}
|
|
|
|
if appErr := PostPriorityCheckWithApp("ScheduledPostJob.postChecks", a, scheduledPost.UserId, scheduledPost.GetPriority(), scheduledPost.RootId); appErr != nil {
|
|
rctx.Logger().Debug(
|
|
"canPostScheduledPost post priority check failed",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("user_id", scheduledPost.UserId),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.String("error_code", model.ScheduledPostErrorInvalidPost),
|
|
mlog.Err(appErr),
|
|
)
|
|
return model.ScheduledPostErrorInvalidPost, nil
|
|
}
|
|
|
|
if appErr := PostCardTypeCheckWithApp("ScheduledPostJob.postChecks", a, scheduledPost.Type); appErr != nil {
|
|
rctx.Logger().Debug(
|
|
"canPostScheduledPost card type disabled",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("user_id", scheduledPost.UserId),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.String("error_code", model.ScheduledPostErrorInvalidPost),
|
|
mlog.Err(appErr),
|
|
)
|
|
return model.ScheduledPostErrorInvalidPost, nil
|
|
}
|
|
|
|
// Validate burn-on-read restrictions for scheduled post
|
|
if appErr := PostBurnOnReadCheckWithApp("ScheduledPostJob.postChecks", a, rctx, scheduledPost.UserId, scheduledPost.ChannelId, scheduledPost.Type, channel); appErr != nil {
|
|
rctx.Logger().Debug(
|
|
"canPostScheduledPost burn-on-read check failed",
|
|
mlog.String("scheduled_post_id", scheduledPost.Id),
|
|
mlog.String("user_id", scheduledPost.UserId),
|
|
mlog.String("channel_id", scheduledPost.ChannelId),
|
|
mlog.String("error_code", model.ScheduledPostErrorInvalidPost),
|
|
mlog.Err(appErr),
|
|
)
|
|
return model.ScheduledPostErrorInvalidPost, nil
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
// handleSuccessfulScheduledPosts advances recurring scheduled posts to their next occurrence and
|
|
// permanently deletes completed ones. The two operations touch disjoint rows and run
|
|
// independently, so a store failure in one can't cause reposts in the other.
|
|
func (a *App) handleSuccessfulScheduledPosts(rctx request.CTX, completedScheduledPosts, recurringScheduledPosts []*model.ScheduledPost) error {
|
|
var errs []error
|
|
|
|
if len(recurringScheduledPosts) > 0 {
|
|
if err := a.Srv().Store().ScheduledPost().UpdateRecurringScheduledPosts(recurringScheduledPosts); err != nil {
|
|
rctx.Logger().Error(
|
|
"App.handleSuccessfulScheduledPosts: failed to advance recurring scheduled posts",
|
|
mlog.Int("recurring_scheduled_post_count", len(recurringScheduledPosts)),
|
|
mlog.Err(err),
|
|
)
|
|
errs = append(errs, errors.Wrap(err, "App.handleSuccessfulScheduledPosts: failed to advance recurring scheduled posts"))
|
|
} else {
|
|
for _, sp := range recurringScheduledPosts {
|
|
a.PublishScheduledPostEvent(rctx, model.WebsocketScheduledPostUpdated, sp, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(completedScheduledPosts) > 0 {
|
|
// Completed scheduled posts can be safely permanently deleted: posted ones have their
|
|
// data moved into the posts table, and ones whose channel is gone can never send.
|
|
toDelete := make([]string, len(completedScheduledPosts))
|
|
for i, sp := range completedScheduledPosts {
|
|
toDelete[i] = sp.Id
|
|
}
|
|
|
|
if err := a.Srv().Store().ScheduledPost().PermanentlyDeleteScheduledPosts(toDelete); err != nil {
|
|
rctx.Logger().Error(
|
|
"App.handleSuccessfulScheduledPosts: failed to delete completed scheduled posts",
|
|
mlog.Int("completed_count", len(toDelete)),
|
|
mlog.Err(err),
|
|
)
|
|
errs = append(errs, errors.Wrap(err, "App.handleSuccessfulScheduledPosts: failed to delete completed scheduled posts"))
|
|
} else {
|
|
for _, sp := range completedScheduledPosts {
|
|
a.PublishScheduledPostEvent(rctx, model.WebsocketScheduledPostDeleted, sp, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
return stderrors.Join(errs...)
|
|
}
|
|
|
|
func (a *App) handleFailedScheduledPosts(rctx request.CTX, failedScheduledPosts []*model.ScheduledPost) {
|
|
for _, failedScheduledPost := range failedScheduledPosts {
|
|
failedScheduledPost.ProcessedAt = model.GetMillis()
|
|
err := a.Srv().Store().ScheduledPost().UpdatedScheduledPost(failedScheduledPost)
|
|
if err != nil {
|
|
// we intentionally don't stop on error as its possible to continue updating other scheduled posts
|
|
rctx.Logger().Error(
|
|
"App.processScheduledPostBatch: failed to updated failed scheduled posts",
|
|
mlog.String("scheduled_post_id", failedScheduledPost.Id),
|
|
mlog.Err(err),
|
|
)
|
|
}
|
|
// send WS event for updating the scheduled post with the error code
|
|
a.PublishScheduledPostEvent(rctx, model.WebsocketScheduledPostUpdated, failedScheduledPost, "")
|
|
}
|
|
|
|
if len(failedScheduledPosts) > 0 {
|
|
a.notifyUserAboutFailedScheduledMessages(rctx, failedScheduledPosts)
|
|
}
|
|
}
|
|
|
|
func (a *App) notifyUserAboutFailedScheduledMessages(rctx request.CTX, failedMessages []*model.ScheduledPost) {
|
|
failedMessagesByUser := aggregateFailMessagesByUser(failedMessages)
|
|
systemBot, err := a.GetSystemBot(rctx)
|
|
if err != nil {
|
|
rctx.Logger().Error("Failed to get the system bot", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
for userId, userFailedMessages := range failedMessagesByUser {
|
|
a.Srv().Go(func(userId string, userFailedMessages []*model.ScheduledPost) func() {
|
|
return func() {
|
|
a.notifyUser(rctx, userId, userFailedMessages, systemBot)
|
|
}
|
|
}(userId, userFailedMessages))
|
|
}
|
|
}
|
|
|
|
func aggregateFailMessagesByUser(failedMessages []*model.ScheduledPost) map[string][]*model.ScheduledPost {
|
|
aggregated := make(map[string][]*model.ScheduledPost)
|
|
for _, msg := range failedMessages {
|
|
aggregated[msg.UserId] = append(aggregated[msg.UserId], msg)
|
|
}
|
|
return aggregated
|
|
}
|
|
|
|
func (a *App) notifyUser(rctx request.CTX, userId string, userFailedMessages []*model.ScheduledPost, systemBot *model.Bot) {
|
|
channel, err := a.GetOrCreateDirectChannel(rctx, userId, systemBot.UserId)
|
|
if err != nil {
|
|
rctx.Logger().Error("Failed to get or create the DM", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
user, err := a.GetUser(userId)
|
|
if err != nil {
|
|
rctx.Logger().Error("Failed to get the user", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
T := i18n.GetUserTranslations(user.Locale)
|
|
|
|
type channelErrorKey struct {
|
|
ChannelId string
|
|
ErrorCode string
|
|
}
|
|
|
|
channelErrorCounts := make(map[channelErrorKey]int)
|
|
channelIdsSet := make(map[string]struct{})
|
|
for _, msg := range userFailedMessages {
|
|
key := channelErrorKey{ChannelId: msg.ChannelId, ErrorCode: msg.ErrorCode}
|
|
channelErrorCounts[key]++
|
|
channelIdsSet[msg.ChannelId] = struct{}{}
|
|
}
|
|
|
|
channelNames := make(map[string]string)
|
|
for channelId := range channelIdsSet {
|
|
ch, err := a.GetChannel(rctx, channelId)
|
|
if err != nil {
|
|
rctx.Logger().Error("Failed to get channel", mlog.String("channel_id", channelId), mlog.Err(err))
|
|
channelNames[channelId] = T("app.scheduled_post.unknown_channel")
|
|
continue
|
|
}
|
|
if ch.Type != model.ChannelTypePrivate {
|
|
channelNames[channelId] = ch.DisplayName
|
|
} else {
|
|
channelNames[channelId] = T("app.scheduled_post.private_channel")
|
|
}
|
|
}
|
|
|
|
var messageBuilder strings.Builder
|
|
|
|
totalFailedMessages := len(userFailedMessages)
|
|
messageHeader := T("app.scheduled_post.failed_messages", map[string]any{
|
|
"Count": totalFailedMessages,
|
|
})
|
|
messageBuilder.WriteString(messageHeader)
|
|
messageBuilder.WriteString("\n")
|
|
|
|
for key, count := range channelErrorCounts {
|
|
channelName := channelNames[key.ChannelId]
|
|
errorReason := getErrorReason(T, key.ErrorCode)
|
|
|
|
detailedMessage := T("app.scheduled_post.failed_message_detail", map[string]any{
|
|
"Count": count,
|
|
"ChannelName": channelName,
|
|
"ErrorReason": errorReason,
|
|
})
|
|
messageBuilder.WriteString(detailedMessage)
|
|
messageBuilder.WriteString("\n")
|
|
}
|
|
|
|
post := &model.Post{
|
|
ChannelId: channel.Id,
|
|
Message: messageBuilder.String(),
|
|
Type: model.PostTypeDefault,
|
|
UserId: systemBot.UserId,
|
|
}
|
|
|
|
if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil {
|
|
rctx.Logger().Error("Failed to post notification about failed scheduled messages", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func getErrorReason(T i18n.TranslateFunc, errorCode string) string {
|
|
var reason string
|
|
switch errorCode {
|
|
case "unknown":
|
|
reason = T("app.scheduled_post.error_reason.unknown")
|
|
case "channel_archived":
|
|
reason = T("app.scheduled_post.error_reason.channel_archived")
|
|
case "channel_not_found":
|
|
reason = T("app.scheduled_post.error_reason.channel_not_found")
|
|
case "user_missing":
|
|
reason = T("app.scheduled_post.error_reason.user_missing")
|
|
case "user_deleted":
|
|
reason = T("app.scheduled_post.error_reason.user_deleted")
|
|
case "no_channel_permission":
|
|
reason = T("app.scheduled_post.error_reason.no_channel_permission")
|
|
case "no_channel_member":
|
|
reason = T("app.scheduled_post.error_reason.no_channel_member")
|
|
case "thread_deleted":
|
|
reason = T("app.scheduled_post.error_reason.thread_deleted")
|
|
case "unable_to_send":
|
|
reason = T("app.scheduled_post.error_reason.unable_to_send")
|
|
case "invalid_post":
|
|
reason = T("app.scheduled_post.error_reason.invalid_post")
|
|
default:
|
|
reason = errorCode
|
|
}
|
|
return reason
|
|
}
|