Scheduled posts: written now, published at their time
POST /api/v1/statuses with scheduled_at no longer posts at once: the post is kept as asked (at least five minutes ahead; 300 waiting, 25 a day, as Mastodon allows), its media kept from the janitor, and a PublishScheduled job publishes it at its time as the persona. scheduled_statuses lists, moves and drops them; a moved post's old job finds it not due. Idempotency-Key holds for scheduling too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
73c362de96
commit
cafd2e6db3
13 files changed
+418
-26
No files matched your search
@@ -157,11 +157,11 @@ namespace PrivaPub.Domain.Media
|
||||
}
|
||||
}
|
||||
|
||||
//one pass: uploads left unattached for a day go, then the proxy cache is trimmed to its size, oldest first
|
||||
//one pass: uploads left unattached for a day go (unless a scheduled post waits for them), then the proxy cache is trimmed to its size, oldest first
|
||||
public async Task Sweep(CancellationToken token)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - UnattachedLifetime;
|
||||
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token))
|
||||
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.ScheduledStatusId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token))
|
||||
await _media.Delete(stale);
|
||||
TrimProxyCache();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
namespace PrivaPub.Domain.Statuses
|
||||
{
|
||||
// Scheduled posts (Mastodon's scheduled_statuses): kept as asked, their media kept from the janitor, and published at
|
||||
// their time by a PublishScheduled job as if the persona posted then. Mastodon's limits hold: at least five minutes
|
||||
// ahead, 300 waiting, 25 on one day.
|
||||
public static class ScheduledStatuses
|
||||
{
|
||||
public static readonly TimeSpan Lead = TimeSpan.FromMinutes(5);
|
||||
public const int MaxWaiting = 300;
|
||||
public const int MaxPerDay = 25;
|
||||
|
||||
public static StatusDraft Draft(ScheduledParams p) => new()
|
||||
{
|
||||
Text = p.Text,
|
||||
PlainText = true,
|
||||
SpoilerText = p.SpoilerText,
|
||||
Sensitive = p.Sensitive,
|
||||
Visibility = Visibility(p.Visibility),
|
||||
InReplyTo = p.InReplyToId,
|
||||
Language = p.Language,
|
||||
MediaIds = p.MediaIds,
|
||||
QuotedStatusId = p.QuotedStatusId,
|
||||
QuotePolicy = p.QuotePolicy,
|
||||
Poll = p.Poll == default ? default : new PollDraft(p.Poll.Options, p.Poll.ExpiresIn, p.Poll.Multiple, p.Poll.HideTotals)
|
||||
};
|
||||
|
||||
public static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch
|
||||
{
|
||||
"unlisted" => PostVisibility.Unlisted,
|
||||
"private" => PostVisibility.FollowersOnly,
|
||||
"direct" => PostVisibility.Direct,
|
||||
_ => PostVisibility.Public
|
||||
};
|
||||
|
||||
// why the persona cannot schedule at this time, or default
|
||||
public static async Task<string> Refusal(string avatarId, DateTime at, string except, CancellationToken token)
|
||||
{
|
||||
if (at < DateTime.UtcNow + Lead)
|
||||
return "Scheduled at The scheduled date must be at least 5 minutes in the future";
|
||||
if (await DB.Default.CountAsync<ScheduledStatus>(s => s.AvatarId == avatarId && s.ID != except, token) >= MaxWaiting)
|
||||
return $"Scheduled at You have exceeded the limit of {MaxWaiting} scheduled statuses";
|
||||
var day = at.Date;
|
||||
var next = day.AddDays(1);
|
||||
if (await DB.Default.CountAsync<ScheduledStatus>(s => s.AvatarId == avatarId && s.ID != except && s.ScheduledAt >= day && s.ScheduledAt < next, token) >= MaxPerDay)
|
||||
return $"Scheduled at You have exceeded the limit of {MaxPerDay} scheduled statuses on that day";
|
||||
return default;
|
||||
}
|
||||
|
||||
// a job per scheduled time: a rescheduled post has a job for its new time, and the old one finds it not due
|
||||
public static Job JobFor(ScheduledStatus scheduled, string host) => new()
|
||||
{
|
||||
Kind = JobKind.PublishScheduled,
|
||||
Payload = scheduled.ID,
|
||||
Host = host,
|
||||
DedupeKey = $"scheduled|{scheduled.ID}|{scheduled.ScheduledAt.Ticks}",
|
||||
RunAt = scheduled.ScheduledAt
|
||||
};
|
||||
|
||||
// the post's media go back to being plain uploads (the janitor's after a day), or are attached to the post by now
|
||||
public static Task Release(string scheduledId, CancellationToken token) =>
|
||||
DB.Default.Update<MediaAttachment>().Match(m => m.ScheduledStatusId == scheduledId).Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token);
|
||||
}
|
||||
|
||||
public class PublishScheduledJob : IJobHandler
|
||||
{
|
||||
readonly IServiceScopeFactory _scopes;
|
||||
readonly ILogger<PublishScheduledJob> _logger;
|
||||
|
||||
public PublishScheduledJob(IServiceScopeFactory scopes, ILogger<PublishScheduledJob> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.PublishScheduled;
|
||||
public int Concurrency => 2;
|
||||
public int MaxAttempts => 3;
|
||||
public int PerHostLimit => 2;
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var scheduled = await DB.Default.Find<ScheduledStatus>().MatchID(job.Payload).ExecuteFirstAsync(token);
|
||||
if (scheduled == default || scheduled.ScheduledAt > DateTime.UtcNow.AddSeconds(30))
|
||||
return JobOutcome.Done;//dropped, or moved to a later time that has its own job
|
||||
using var scope = _scopes.CreateScope();
|
||||
var author = await scope.ServiceProvider.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, scheduled.AvatarId, token);
|
||||
if (author != default)
|
||||
{
|
||||
var outcome = await scope.ServiceProvider.GetRequiredService<IStatusService>().Publish(author, ScheduledStatuses.Draft(scheduled.Params), token);
|
||||
if (!outcome.Ok)
|
||||
_logger.LogWarning("Scheduled status {Id} of {Avatar} could not be published: {Error}", scheduled.ID, scheduled.AvatarId, outcome.Error);
|
||||
}
|
||||
await ScheduledStatuses.Release(scheduled.ID, token);
|
||||
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user