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 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(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(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().Match(m => m.ScheduledStatusId == scheduledId).Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token); } public class PublishScheduledJob : IJobHandler { readonly IServiceScopeFactory _scopes; readonly ILogger _logger; public PublishScheduledJob(IServiceScopeFactory scopes, ILogger 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 Handle(Job job, CancellationToken token) { var scheduled = await DB.Default.Find().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().FindById(LocalActorKind.Person, scheduled.AvatarId, token); if (author != default) { var outcome = await scope.ServiceProvider.GetRequiredService().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(scheduled.ID); return JobOutcome.Done; } } }