Hackathon: Post Reminders (#20555)
This PR adds the post reminder backend work. We add a new API endpoint via which a user can set a reminder for a post. An ephemeral message will be sent down the line to let the user know about the action. And then after the time is over, the system admin bot will send a DM message to the user about the reminder post.
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
e6459b97de
Коммит
20cb042362
@@ -446,6 +446,7 @@ type AppIface interface {
|
||||
CheckIntegrity() <-chan model.IntegrityCheckResult
|
||||
CheckMandatoryS3Fields(settings *model.FileSettings) *model.AppError
|
||||
CheckPasswordAndAllCriteria(user *model.User, password string, mfaToken string) *model.AppError
|
||||
CheckPostReminders()
|
||||
CheckRolesExist(roleNames []string) *model.AppError
|
||||
CheckUserAllAuthenticationCriteria(user *model.User, mfaToken string) *model.AppError
|
||||
CheckUserMfa(user *model.User, token string) *model.AppError
|
||||
@@ -1041,6 +1042,7 @@ type AppIface interface {
|
||||
SetPluginKey(pluginID string, key string, value []byte) *model.AppError
|
||||
SetPluginKeyWithExpiry(pluginID string, key string, value []byte, expireInSeconds int64) *model.AppError
|
||||
SetPluginKeyWithOptions(pluginID string, key string, value []byte, options model.PluginKVSetOptions) (bool, *model.AppError)
|
||||
SetPostReminder(postID, userID string, targetTime int64) *model.AppError
|
||||
SetProfileImage(userID string, imageData *multipart.FileHeader) *model.AppError
|
||||
SetProfileImageFromFile(userID string, file io.Reader) *model.AppError
|
||||
SetProfileImageFromMultiPartFile(userID string, file multipart.File) *model.AppError
|
||||
|
||||
@@ -86,6 +86,9 @@ type Channels struct {
|
||||
|
||||
dndTaskMut sync.Mutex
|
||||
dndTask *model.ScheduledTask
|
||||
|
||||
postReminderMut sync.Mutex
|
||||
postReminderTask *model.ScheduledTask
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -1310,6 +1310,21 @@ func (a *OpenTracingAppLayer) CheckPasswordAndAllCriteria(user *model.User, pass
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CheckPostReminders() {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckPostReminders")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
a.app.CheckPostReminders()
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CheckProviderAttributes(user *model.User, patch *model.UserPatch) string {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckProviderAttributes")
|
||||
@@ -15592,6 +15607,28 @@ func (a *OpenTracingAppLayer) SetPluginKeyWithOptions(pluginID string, key strin
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SetPostReminder(postID string, userID string, targetTime int64) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SetPostReminder")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.SetPostReminder(postID, userID, targetTime)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SetProfileImage(userID string, imageData *multipart.FileHeader) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SetProfileImage")
|
||||
|
||||
120
app/post.go
120
app/post.go
@@ -1933,6 +1933,126 @@ func (a *App) GetTopThreadsForUserSince(c request.CTX, teamID, userID string, op
|
||||
return topThreadsWithEmbedAndImage, nil
|
||||
}
|
||||
|
||||
func (a *App) SetPostReminder(postID, userID string, targetTime int64) *model.AppError {
|
||||
// Store the reminder in the DB
|
||||
reminder := &model.PostReminder{
|
||||
PostId: postID,
|
||||
UserId: userID,
|
||||
TargetTime: targetTime,
|
||||
}
|
||||
err := a.Srv().Store.Post().SetPostReminder(reminder)
|
||||
if err != nil {
|
||||
return model.NewAppError("SetPostReminder", "app.post_reminder.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
metadata, err := a.Srv().Store.Post().GetPostReminderMetadata(postID)
|
||||
if err != nil {
|
||||
return model.NewAppError("SetPostReminder", "app.post_reminder.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
parsed := time.Unix(targetTime, 0).UTC().Format(time.RFC822)
|
||||
siteURL := *a.Config().ServiceSettings.SiteURL
|
||||
// Send an ack message.
|
||||
ephemeralPost := &model.Post{
|
||||
Type: model.PostTypeEphemeral,
|
||||
Id: model.NewId(),
|
||||
CreateAt: model.GetMillis(),
|
||||
UserId: userID,
|
||||
RootId: postID,
|
||||
ChannelId: metadata.ChannelId,
|
||||
// It's okay to keep this non-translated. This is just a fallback.
|
||||
// The webapp will parse the timestamp and show that in user's local timezone.
|
||||
Message: fmt.Sprintf("You will be reminded about %s/%s/pl/%s by @%s at %s", siteURL, metadata.TeamName, postID, metadata.Username, parsed),
|
||||
Props: model.StringInterface{
|
||||
"target_time": targetTime,
|
||||
"team_name": metadata.TeamName,
|
||||
"post_id": postID,
|
||||
"username": metadata.Username,
|
||||
"type": model.PostTypeReminder,
|
||||
},
|
||||
}
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", ephemeralPost.ChannelId, userID, nil)
|
||||
ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(request.EmptyContext(a.Log()), ephemeralPost, true, false)
|
||||
ephemeralPost = model.AddPostActionCookies(ephemeralPost, a.PostActionCookieSecret())
|
||||
|
||||
postJSON, jsonErr := ephemeralPost.ToJSON()
|
||||
if jsonErr != nil {
|
||||
mlog.Warn("Failed to encode post to JSON", mlog.Err(jsonErr))
|
||||
}
|
||||
message.Add("post", postJSON)
|
||||
a.Publish(message)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CheckPostReminders() {
|
||||
systemBot, appErr := a.GetSystemBot()
|
||||
if appErr != nil {
|
||||
mlog.Error("Failed to get system bot", mlog.Err(appErr))
|
||||
return
|
||||
}
|
||||
|
||||
// This will return the reminders and also delete them from the DB.
|
||||
// In case, any of the next steps fail, those reminders would be lost.
|
||||
// Alternatively, if we delete those reminders _after_ it has been sent,
|
||||
// then in case of any temporary failure, they would get sent in the next batch.
|
||||
// MM-45595.
|
||||
reminders, err := a.Srv().Store.Post().GetPostReminders(time.Now().UTC().Unix())
|
||||
if err != nil {
|
||||
mlog.Error("Failed to get post reminders", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
// We group multiple reminders for a single user.
|
||||
groupedReminders := make(map[string][]string)
|
||||
for _, r := range reminders {
|
||||
if groupedReminders[r.UserId] == nil {
|
||||
groupedReminders[r.UserId] = []string{r.PostId}
|
||||
} else {
|
||||
groupedReminders[r.UserId] = append(groupedReminders[r.UserId], r.PostId)
|
||||
}
|
||||
}
|
||||
|
||||
siteURL := *a.Config().ServiceSettings.SiteURL
|
||||
for userID, postIDs := range groupedReminders {
|
||||
ch, appErr := a.GetOrCreateDirectChannel(request.EmptyContext(a.Log()), userID, systemBot.UserId)
|
||||
if appErr != nil {
|
||||
mlog.Error("Failed to get direct channel", mlog.Err(appErr))
|
||||
return
|
||||
}
|
||||
|
||||
for _, postID := range postIDs {
|
||||
metadata, err := a.Srv().Store.Post().GetPostReminderMetadata(postID)
|
||||
if err != nil {
|
||||
mlog.Error("Failed to get post reminder metadata", mlog.Err(err))
|
||||
continue
|
||||
}
|
||||
|
||||
T := i18n.GetUserTranslations(metadata.UserLocale)
|
||||
dm := &model.Post{
|
||||
ChannelId: ch.Id,
|
||||
Message: T("app.post_reminder_dm", model.StringInterface{
|
||||
"SiteURL": siteURL,
|
||||
"TeamName": metadata.TeamName,
|
||||
"PostId": postID,
|
||||
"Username": metadata.Username,
|
||||
}),
|
||||
Type: model.PostTypeDefault,
|
||||
UserId: systemBot.UserId,
|
||||
Props: model.StringInterface{
|
||||
"username": systemBot.Username,
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := a.CreatePost(request.EmptyContext(a.Log()), dm, ch, false, true); err != nil {
|
||||
mlog.Error("Failed to post reminder message", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThreadList, userID string) (*model.TopThreadList, error) {
|
||||
for _, topThread := range topThreadList.Items {
|
||||
topThread.Post = a.PreparePostForClientWithEmbedsAndImages(c, topThread.Post, false, false)
|
||||
|
||||
@@ -681,6 +681,7 @@ func NewServer(options ...Option) (*Server, error) {
|
||||
s.runLicenseExpirationCheckJob()
|
||||
s.runInactivityCheckJob()
|
||||
runDNDStatusExpireJob(appInstance)
|
||||
runPostReminderJob(appInstance)
|
||||
})
|
||||
s.runJobs()
|
||||
}
|
||||
@@ -2066,31 +2067,53 @@ func (s *Server) ReadFile(path string) ([]byte, *model.AppError) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func createDNDStatusExpirationRecurringTask(a *App) {
|
||||
a.ch.dndTaskMut.Lock()
|
||||
a.ch.dndTask = model.CreateRecurringTaskFromNextIntervalTime("Unset DND Statuses", a.UpdateDNDStatusOfUsers, 5*time.Minute)
|
||||
a.ch.dndTaskMut.Unlock()
|
||||
func withMut(mut *sync.Mutex, f func()) {
|
||||
mut.Lock()
|
||||
defer mut.Unlock()
|
||||
f()
|
||||
}
|
||||
|
||||
func cancelDNDStatusExpirationRecurringTask(a *App) {
|
||||
a.ch.dndTaskMut.Lock()
|
||||
if a.ch.dndTask != nil {
|
||||
a.ch.dndTask.Cancel()
|
||||
a.ch.dndTask = nil
|
||||
func cancelTask(mut *sync.Mutex, task *model.ScheduledTask) {
|
||||
mut.Lock()
|
||||
defer mut.Unlock()
|
||||
if task != nil {
|
||||
task.Cancel()
|
||||
task = nil
|
||||
}
|
||||
a.ch.dndTaskMut.Unlock()
|
||||
}
|
||||
|
||||
func runDNDStatusExpireJob(a *App) {
|
||||
if a.IsLeader() {
|
||||
createDNDStatusExpirationRecurringTask(a)
|
||||
withMut(&a.ch.dndTaskMut, func() {
|
||||
a.ch.dndTask = model.CreateRecurringTaskFromNextIntervalTime("Unset DND Statuses", a.UpdateDNDStatusOfUsers, 5*time.Minute)
|
||||
})
|
||||
}
|
||||
a.ch.srv.AddClusterLeaderChangedListener(func() {
|
||||
mlog.Info("Cluster leader changed. Determining if unset DNS status task should be running", mlog.Bool("isLeader", a.IsLeader()))
|
||||
if a.IsLeader() {
|
||||
createDNDStatusExpirationRecurringTask(a)
|
||||
withMut(&a.ch.dndTaskMut, func() {
|
||||
a.ch.dndTask = model.CreateRecurringTaskFromNextIntervalTime("Unset DND Statuses", a.UpdateDNDStatusOfUsers, 5*time.Minute)
|
||||
})
|
||||
} else {
|
||||
cancelDNDStatusExpirationRecurringTask(a)
|
||||
cancelTask(&a.ch.dndTaskMut, a.ch.dndTask)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func runPostReminderJob(a *App) {
|
||||
if a.IsLeader() {
|
||||
withMut(&a.ch.postReminderMut, func() {
|
||||
a.ch.postReminderTask = model.CreateRecurringTaskFromNextIntervalTime("Check Post reminders", a.CheckPostReminders, 5*time.Minute)
|
||||
})
|
||||
}
|
||||
a.ch.srv.AddClusterLeaderChangedListener(func() {
|
||||
mlog.Info("Cluster leader changed. Determining if post reminder task should be running", mlog.Bool("isLeader", a.IsLeader()))
|
||||
if a.IsLeader() {
|
||||
withMut(&a.ch.postReminderMut, func() {
|
||||
a.ch.postReminderTask = model.CreateRecurringTaskFromNextIntervalTime("Check Post reminders", a.CheckPostReminders, 5*time.Minute)
|
||||
})
|
||||
} else {
|
||||
cancelTask(&a.ch.postReminderMut, a.ch.postReminderTask)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user