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:
thepraandClaude Opus 5.5 committed 2026-10-05 02:55:17 +02:00
1 parent 73c362de96
commit cafd2e6db3
13 files changed
+418 -26

No files matched your search

@@ -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
@@ -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<object> View(ScheduledStatus scheduled, IMediaService media, CancellationToken token)
{
var p = scheduled.Params;
var ids = p.MediaIds;
var attachments = ids.Count == 0
? new List<MediaAttachment>()
: await DB.Default.Find<MediaAttachment>().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<ScheduledStatus> Mine(string id, CancellationToken token) =>
DB.Default.Find<ScheduledStatus>().Match(s => s.ID == id && s.AvatarId == MyId).ExecuteFirstAsync(token);
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
public async Task<IActionResult> All(CancellationToken token)
{
var page = await Page.From(Params, Limit(20, 40)).Fetch(DB.Default.Find<ScheduledStatus>().Match(s => s.AvatarId == MyId), s => s.ID, token);
Link("/api/v1/scheduled_statuses", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
var views = new List<object>();
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<IActionResult> 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<IActionResult> 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<ScheduledStatus>().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<IActionResult> Drop(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } scheduled)
return NotFoundError();
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
await ScheduledStatuses.Release(scheduled.ID, token);
return Json(new { });
}
}
}
@@ -114,9 +114,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
public IActionResult FeaturedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
public IActionResult ScheduledStatuses() => Json(Array.Empty<object>());
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
public IActionResult Preferences() => Json(new Dictionary<string, object>
{
@@ -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<IActionResult> Schedule(ScheduledParams asked, string scheduledAt, string cacheKey, CancellationToken token)
{
if (cacheKey != default && _cache.TryGetValue(cacheKey + ":scheduled", out string earlier)
&& await DB.Default.Find<ScheduledStatus>().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<Models.Media.MediaAttachment>(
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<Models.Media.MediaAttachment>().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<IActionResult> 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);
}
}