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
@@ -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");
|
||||
|
||||
@@ -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<Job>().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<MediaAttachment>().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<ScheduledStatus>().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token);
|
||||
await DB.Default.Update<Job>().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<MediaAttachment>().MatchID(mediaId).ExecuteFirstAsync(Token);
|
||||
Assert.Equal(published.Text("id"), media.PostId);
|
||||
Assert.Null(media.ScheduledStatusId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
||||
.AddSingleton<IJobHandler, PollCloseJob>()
|
||||
.AddSingleton<IJobHandler, Domain.Statuses.PublishScheduledJob>()
|
||||
.AddSingleton<InstanceDescriber>()
|
||||
.AddSingleton<IJobHandler>(services => services.GetRequiredService<InstanceDescriber>())
|
||||
.AddSingleton<IJobHandler, AncestorsJobHandler>()
|
||||
|
||||
@@ -31,7 +31,8 @@ namespace PrivaPub.Models.Jobs
|
||||
CrawlPlan,
|
||||
CrawlInstance,
|
||||
SendRecovery,
|
||||
CountAccount
|
||||
CountAccount,
|
||||
PublishScheduled
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string> 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<string> Options { get; set; } = new();
|
||||
public int ExpiresIn { get; set; }
|
||||
public bool Multiple { get; set; }
|
||||
public bool HideTotals { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user