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
109 lines
4.5 KiB
C#
109 lines
4.5 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|