From cafd2e6db31a9cb6354a40f6c3311eefc49d5d77 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 02:55:17 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Http/MastodonInstanceTests.cs | 1 - .../Http/MastodonScheduledStatusesTests.cs | 103 +++++++++++++++++ .../Mastodon/Controllers/MediaController.cs | 8 +- .../ScheduledStatusesController.cs | 103 +++++++++++++++++ .../Mastodon/Controllers/SearchController.cs | 3 - .../Controllers/StatusesController.cs | 68 ++++++++--- PrivaPub/Domain/Media/MediaProxy.cs | 4 +- PrivaPub/Domain/Statuses/ScheduledStatuses.cs | 108 ++++++++++++++++++ .../Middleware/SocialPubConfigurations.cs | 1 + PrivaPub/Models/Jobs/Job.cs | 3 +- PrivaPub/Models/Media/MediaAttachment.cs | 1 + PrivaPub/Models/Social/ScheduledStatus.cs | 37 ++++++ docs/ROADMAP.md | 4 + 13 files changed, 418 insertions(+), 26 deletions(-) create mode 100644 PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs create mode 100644 PrivaPub/Api/Mastodon/Controllers/ScheduledStatusesController.cs create mode 100644 PrivaPub/Domain/Statuses/ScheduledStatuses.cs create mode 100644 PrivaPub/Models/Social/ScheduledStatus.cs diff --git a/PrivaPub.Tests/Http/MastodonInstanceTests.cs b/PrivaPub.Tests/Http/MastodonInstanceTests.cs index 6c9812a..361d770 100644 --- a/PrivaPub.Tests/Http/MastodonInstanceTests.cs +++ b/PrivaPub.Tests/Http/MastodonInstanceTests.cs @@ -84,7 +84,6 @@ namespace PrivaPub.Tests.Http [InlineData("/api/v1/followed_tags")] [InlineData("/api/v1/endorsements")] [InlineData("/api/v1/featured_tags")] - [InlineData("/api/v1/scheduled_statuses")] public async Task Signed_in_stub_routes_answer_empty_lists(string path) { var reader = await _host.Mastodon("reader"); diff --git a/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs b/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs new file mode 100644 index 0000000..0a8fca0 --- /dev/null +++ b/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs @@ -0,0 +1,103 @@ +using MongoDB.Entities; + +using PrivaPub.Models.Jobs; +using PrivaPub.Models.Media; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; + +namespace PrivaPub.Tests.Http +{ + // Mastodon's scheduled statuses: a post given a scheduled_at waits as asked, can be moved or dropped, and is + // published by its job when its time comes + [Trait("Category", "Integration")] + public sealed class MastodonScheduledStatusesTests : IAsyncLifetime + { + PrivaPubHost _host; + + static CancellationToken Token => TestContext.Current.CancellationToken; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + static string In(TimeSpan from) => DateTime.UtcNow.Add(from).ToString("o"); + + [Fact] + public async Task A_scheduled_post_waits_is_moved_and_is_dropped_by_its_owner_only() + { + var alice = await _host.Mastodon("alice"); + var mallory = await _host.Mastodon("mallory"); + + var scheduled = (await alice.Client.Post("/api/v1/statuses", ("status", "later, friends"), ("visibility", "unlisted"), + ("spoiler_text", "soon"), ("scheduled_at", In(TimeSpan.FromMinutes(30))))).Ok().Object; + var id = scheduled.Text("id"); + Assert.Null(scheduled["uri"]); + Assert.Equal("later, friends", scheduled["params"].Text("text")); + Assert.Equal("unlisted", scheduled["params"].Text("visibility")); + Assert.Equal("soon", scheduled["params"].Text("spoiler_text")); + Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array); + Assert.Equal(new[] { id }, (await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Ids); + + var later = DateTime.UtcNow.AddHours(2); + var moved = (await alice.Client.Put($"/api/v1/scheduled_statuses/{id}", ("scheduled_at", later.ToString("o")))).Ok().Object; + Assert.Equal(later.ToString("yyyy-MM-ddTHH:mm"), moved.Text("scheduled_at")[..16]); + Assert.Equal(HttpStatusCode.UnprocessableEntity, + (await alice.Client.Put($"/api/v1/scheduled_statuses/{id}", ("scheduled_at", In(TimeSpan.FromMinutes(2))))).Status); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, + (await alice.Client.Post("/api/v1/statuses", ("status", "too soon"), ("scheduled_at", In(TimeSpan.FromMinutes(1))))).Status); + Assert.Equal(HttpStatusCode.UnprocessableEntity, + (await alice.Client.Post("/api/v1/statuses", ("status", " "), ("scheduled_at", In(TimeSpan.FromHours(1))))).Status); + Assert.Empty((await mallory.Client.Get("/api/v1/scheduled_statuses")).Ok().Array); + Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/scheduled_statuses/{id}")).Status); + Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v1/scheduled_statuses/{id}")).Status); + Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/scheduled_statuses")).Status); + + (await alice.Client.Delete($"/api/v1/scheduled_statuses/{id}")).Ok(); + Assert.Empty((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array); + await DB.Default.Update().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id) + .Modify(j => j.RunAt, DateTime.UtcNow).ExecuteAsync(Token); + await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token); + Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array); + } + + [Fact] + public async Task A_scheduled_post_is_published_with_its_media_when_its_time_comes() + { + var alice = await _host.Mastodon("alice"); + var upload = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(16, 16), "image/jpeg", "a.jpg")); + var mediaId = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = upload })).Ok().Body.Text("id"); + + var id = (await alice.Client.Post("/api/v1/statuses", ("status", "on time"), ("media_ids[]", mediaId), ("poll[options][]", "yes"), + ("poll[options][]", "no"), ("poll[expires_in]", "3600"), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok().Object.Text("id"); + Assert.Equal(id, (await DB.Default.Find().MatchID(mediaId).ExecuteFirstAsync(Token)).ScheduledStatusId); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "same media"), ("media_ids[]", mediaId), + ("scheduled_at", In(TimeSpan.FromMinutes(20))))).Status); + + // not due yet: its job leaves it waiting + Assert.Equal(1, await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token)); + Assert.Single((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array); + + // its time comes (moved into the past, as the clock would have it) + await DB.Default.Update().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token); + await DB.Default.Update().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id).Modify(j => j.State, JobState.Pending).ExecuteAsync(Token); + await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token); + + Assert.Empty((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array); + var published = Assert.Single((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array)!; + Assert.Contains("on time", published.Text("content")); + Assert.Equal(mediaId, Assert.Single(published["media_attachments"]!.AsArray()).Text("id")); + Assert.Equal(2, published["poll"]!["options"]!.AsArray().Count); + var media = await DB.Default.Find().MatchID(mediaId).ExecuteFirstAsync(Token); + Assert.Equal(published.Text("id"), media.PostId); + Assert.Null(media.ScheduledStatusId); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs index 71928ff..101e99b 100644 --- a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs @@ -101,12 +101,14 @@ namespace PrivaPub.Api.Mastodon.Controllers return new EmptyResult(); } - object View(MediaAttachment attachment) => new + object View(MediaAttachment attachment) => View(_media, attachment); + + internal static object View(IMediaService media, MediaAttachment attachment) => new { id = attachment.ID, type = attachment.Kind, - url = _media.Url(attachment.FilePath), - preview_url = _media.Url(attachment.PreviewPath ?? attachment.FilePath), + url = media.Url(attachment.FilePath), + preview_url = media.Url(attachment.PreviewPath ?? attachment.FilePath), remote_url = default(string), text_url = default(string), meta = new diff --git a/PrivaPub/Api/Mastodon/Controllers/ScheduledStatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/ScheduledStatusesController.cs new file mode 100644 index 0000000..d8c6f47 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/ScheduledStatusesController.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Mvc; + +using MongoDB.Entities; + +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Domain.Media; +using PrivaPub.Domain.Statuses; +using PrivaPub.Infrastructure.Jobs; +using PrivaPub.Models.Media; +using PrivaPub.Models.Social; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + // Mastodon's scheduled statuses (owner decision 2026-10-04, back from the cut list): the persona's posts waiting for + // their time, moved to another time or dropped. POST /api/v1/statuses with scheduled_at makes them. + public class ScheduledStatusesController : MastodonController + { + readonly IMediaService _media; + readonly IJobQueue _jobs; + + public ScheduledStatusesController(IMediaService media, IJobQueue jobs) + { + _media = media; + _jobs = jobs; + } + + public static async Task View(ScheduledStatus scheduled, IMediaService media, CancellationToken token) + { + var p = scheduled.Params; + var ids = p.MediaIds; + var attachments = ids.Count == 0 + ? new List() + : await DB.Default.Find().Match(m => ids.Contains(m.ID)).ExecuteAsync(token); + return new + { + id = scheduled.ID, + scheduled_at = MastodonJson.Time(scheduled.ScheduledAt), + @params = new + { + text = p.Text, + poll = p.Poll == default ? default : new { options = p.Poll.Options, expires_in = p.Poll.ExpiresIn, multiple = p.Poll.Multiple, hide_totals = p.Poll.HideTotals }, + media_ids = ids.Count == 0 ? default : ids, + sensitive = p.Sensitive, + spoiler_text = p.SpoilerText, + visibility = p.Visibility, + in_reply_to_id = p.InReplyToId, + language = p.Language, + application_id = default(string), + scheduled_at = default(string), + idempotency = p.Idempotency, + with_rate_limit = false, + quoted_status_id = p.QuotedStatusId + }, + media_attachments = ids.Select(id => attachments.FirstOrDefault(m => m.ID == id)).Where(m => m != default) + .Select(m => MediaController.View(media, m)).ToList() + }; + } + + Task Mine(string id, CancellationToken token) => + DB.Default.Find().Match(s => s.ID == id && s.AvatarId == MyId).ExecuteFirstAsync(token); + + [HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")] + public async Task All(CancellationToken token) + { + var page = await Page.From(Params, Limit(20, 40)).Fetch(DB.Default.Find().Match(s => s.AvatarId == MyId), s => s.ID, token); + Link("/api/v1/scheduled_statuses", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID); + var views = new List(); + foreach (var scheduled in page) + views.Add(await View(scheduled, _media, token)); + return Json(views); + } + + [HttpGet("/api/v1/scheduled_statuses/{id}"), Scope("read:statuses")] + public async Task One(string id, CancellationToken token) => + await Mine(id, token) is { } scheduled ? Json(await View(scheduled, _media, token)) : NotFoundError(); + + [HttpPut("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")] + public async Task Move(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } scheduled) + return NotFoundError(); + if (!DateTime.TryParse(Params.Get("scheduled_at"), System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at)) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid"); + if (await ScheduledStatuses.Refusal(MyId, at, scheduled.ID, token) is { } refusal) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal); + scheduled.ScheduledAt = at; + await DB.Default.Update().MatchID(scheduled.ID).Modify(s => s.ScheduledAt, at).ExecuteAsync(token); + await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token); + return Json(await View(scheduled, _media, token)); + } + + [HttpDelete("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")] + public async Task Drop(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } scheduled) + return NotFoundError(); + await DB.Default.DeleteAsync(scheduled.ID); + await ScheduledStatuses.Release(scheduled.ID, token); + return Json(new { }); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index 24d7329..c759e8f 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -114,9 +114,6 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/featured_tags"), Scope("read:accounts")] public IActionResult FeaturedTags() => Json(Array.Empty()); - [HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")] - public IActionResult ScheduledStatuses() => Json(Array.Empty()); - [HttpGet("/api/v1/preferences"), Scope("read:accounts")] public IActionResult Preferences() => Json(new Dictionary { diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 1d737d1..8fed7e7 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -9,6 +9,7 @@ using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Statuses; using PrivaPub.Models.Post; +using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Outbox; @@ -29,10 +30,14 @@ namespace PrivaPub.Api.Mastodon.Controllers readonly IMemoryCache _cache; readonly IQuoteService _quotes; readonly IOutboxPublisher _outbox; + readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs; + readonly Domain.Media.IMediaService _media; public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes, - IOutboxPublisher outbox) + IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media) { + _jobs = jobs; + _media = media; _quotes = quotes; _outbox = outbox; _statuses = statuses; @@ -52,23 +57,31 @@ namespace PrivaPub.Api.Mastodon.Controllers if (existing != default) return Json(await _mapper.Status(existing, MyId, token)); } - var outcome = await _statuses.Publish(Me, new StatusDraft + var asked = new ScheduledParams { Text = Params.Get("status"), - PlainText = true, SpoilerText = Params.Get("spoiler_text"), Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive, - Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility), - InReplyTo = Params.Get("in_reply_to_id"), + Visibility = Params.Get("visibility") ?? Me.Settings.DefaultVisibility, + InReplyToId = Params.Get("in_reply_to_id"), Language = Params.Get("language") ?? Me.Settings.DefaultLanguage, - MediaIds = Params.List("media_ids"), + MediaIds = Params.List("media_ids").ToList(), QuotedStatusId = Params.Get("quoted_status_id"), QuotePolicy = Params.Get("quote_approval_policy"), + Idempotency = string.IsNullOrEmpty(idempotency) ? default : idempotency, Poll = Params.Has("poll[options]") - ? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true, - Params.Bool("poll[hide_totals]") == true) + ? new ScheduledPoll + { + Options = Params.List("poll[options]").ToList(), + ExpiresIn = Params.Int("poll[expires_in]") ?? 0, + Multiple = Params.Bool("poll[multiple]") == true, + HideTotals = Params.Bool("poll[hide_totals]") == true + } : default - }, token); + }; + if (Params.Get("scheduled_at") is { Length: > 0 } scheduledAt) + return await Schedule(asked, scheduledAt, cacheKey, token); + var outcome = await _statuses.Publish(Me, ScheduledStatuses.Draft(asked), token); if (!outcome.Ok) return Error(outcome.Status, outcome.Error); if (cacheKey != default) @@ -76,6 +89,35 @@ namespace PrivaPub.Api.Mastodon.Controllers return Json(await _mapper.Status(outcome.Post, MyId, token)); } + // scheduled_at: the post waits, as asked, for a PublishScheduled job at that time; what it would need then (words, + // media of the persona's own, not yet posted) is checked now + async Task Schedule(ScheduledParams asked, string scheduledAt, string cacheKey, CancellationToken token) + { + if (cacheKey != default && _cache.TryGetValue(cacheKey + ":scheduled", out string earlier) + && await DB.Default.Find().MatchID(earlier).ExecuteFirstAsync(token) is { } waiting) + return Json(await ScheduledStatusesController.View(waiting, _media, token)); + if (!DateTime.TryParse(scheduledAt, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at)) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid"); + if (await ScheduledStatuses.Refusal(MyId, at, default, token) is { } refusal) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal); + if (string.IsNullOrWhiteSpace(asked.Text) && asked.MediaIds.Count == 0 && asked.Poll == default) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank"); + var mediaIds = asked.MediaIds.Distinct().ToList(); + if (mediaIds.Count > 4 || mediaIds.Count > 0 && await DB.Default.CountAsync( + m => mediaIds.Contains(m.ID) && m.OwnerAvatarId == MyId && m.PostId == null && m.ScheduledStatusId == null, token) != mediaIds.Count) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many"); + + var scheduled = new ScheduledStatus { AvatarId = MyId, ScheduledAt = at, Params = asked }; + await DB.Default.SaveAsync(scheduled, token); + if (mediaIds.Count > 0) + await DB.Default.Update().Match(m => mediaIds.Contains(m.ID)).Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token); + await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token); + if (cacheKey != default) + _cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1)); + return Json(await ScheduledStatusesController.View(scheduled, _media, token)); + } + [HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Many(CancellationToken token) { @@ -375,12 +417,6 @@ namespace PrivaPub.Api.Mastodon.Controllers return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default; } - static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch - { - "unlisted" => PostVisibility.Unlisted, - "private" => PostVisibility.FollowersOnly, - "direct" => PostVisibility.Direct, - _ => PostVisibility.Public - }; + static PostVisibility Visibility(string value) => ScheduledStatuses.Visibility(value); } } diff --git a/PrivaPub/Domain/Media/MediaProxy.cs b/PrivaPub/Domain/Media/MediaProxy.cs index a58040b..1cc5ea3 100644 --- a/PrivaPub/Domain/Media/MediaProxy.cs +++ b/PrivaPub/Domain/Media/MediaProxy.cs @@ -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().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token)) + foreach (var stale in await DB.Default.Find().Match(m => m.PostId == null && m.ScheduledStatusId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token)) await _media.Delete(stale); TrimProxyCache(); } diff --git a/PrivaPub/Domain/Statuses/ScheduledStatuses.cs b/PrivaPub/Domain/Statuses/ScheduledStatuses.cs new file mode 100644 index 0000000..f73c8bb --- /dev/null +++ b/PrivaPub/Domain/Statuses/ScheduledStatuses.cs @@ -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 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; + } + } +} diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 3b8a4f6..2b772d7 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -105,6 +105,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(services => services.GetRequiredService()) .AddSingleton() diff --git a/PrivaPub/Models/Jobs/Job.cs b/PrivaPub/Models/Jobs/Job.cs index 115790b..b3fd794 100644 --- a/PrivaPub/Models/Jobs/Job.cs +++ b/PrivaPub/Models/Jobs/Job.cs @@ -31,7 +31,8 @@ namespace PrivaPub.Models.Jobs CrawlPlan, CrawlInstance, SendRecovery, - CountAccount + CountAccount, + PublishScheduled } public enum JobState diff --git a/PrivaPub/Models/Media/MediaAttachment.cs b/PrivaPub/Models/Media/MediaAttachment.cs index 4bad40a..e60527f 100644 --- a/PrivaPub/Models/Media/MediaAttachment.cs +++ b/PrivaPub/Models/Media/MediaAttachment.cs @@ -18,6 +18,7 @@ namespace PrivaPub.Models.Media public float[] Focus { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? AttachedAt { get; set; } + public string ScheduledStatusId { get; set; }//kept for a scheduled post until it is published or dropped } public class MediaSecret : Entity diff --git a/PrivaPub/Models/Social/ScheduledStatus.cs b/PrivaPub/Models/Social/ScheduledStatus.cs new file mode 100644 index 0000000..4622894 --- /dev/null +++ b/PrivaPub/Models/Social/ScheduledStatus.cs @@ -0,0 +1,37 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + //a post a persona wrote to be published later (Mastodon's scheduled statuses, owner decision 2026-10-04): what it + //asked for, kept as it asked, published by a PublishScheduled job at ScheduledAt. Nothing leaves before then. + public class ScheduledStatus : Entity + { + public string AvatarId { get; set; } + public DateTime ScheduledAt { get; set; } + public ScheduledParams Params { get; set; } = new(); + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public class ScheduledParams + { + public string Text { get; set; } + public string SpoilerText { get; set; } + public bool Sensitive { get; set; } + public string Visibility { get; set; }//public, unlisted, private, direct + public string InReplyToId { get; set; } + public string Language { get; set; } + public List MediaIds { get; set; } = new(); + public ScheduledPoll Poll { get; set; } + public string QuotedStatusId { get; set; } + public string QuotePolicy { get; set; } + public string Idempotency { get; set; } + } + + public class ScheduledPoll + { + public List Options { get; set; } = new(); + public int ExpiresIn { get; set; } + public bool Multiple { get; set; } + public bool HideTotals { get; set; } + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fe7d4ec..4e2b1d3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,6 +68,9 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati persona's own and never federate. - [x] Filters: v2 (keywords, whole words, statuses, contexts, warn/hide/blur, expiry) and v1 over their keywords; every status carries the filters it matches in `filtered`, and clients apply context and action, as Mastodon's do. + - [x] Scheduled posts: `scheduled_at` (at least five minutes ahead; 300 waiting, 25 a day) keeps the post as asked, + with its media kept from the janitor, and a `PublishScheduled` job publishes it at its time; `scheduled_statuses` + lists, moves and drops them. ## Intent @@ -453,6 +456,7 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol `resolve`. - **Lists:** CRUD, `:id/accounts`, `accounts/:id/lists`, `timelines/list/:id` (P9). - **Filters:** v2 with keywords and statuses, v1, `filtered` on statuses (P9). + - **Scheduled statuses:** `scheduled_at` on posting, `scheduled_statuses` (P9). - **Stubs:** `custom_emojis`, announcements, trends, suggestions, `followed_tags`, preferences. - **Mapping:** - Avatar, ForeignAvatar and Group all map to Account (`group: true` for groups). `acct` uses a WebFinger-verified