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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user