- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed from content when the real quote is shown. - Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts are delivered to the quoted author too. - Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7. Checked live: GoToSocial's author-only quote policy is respected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
113 lines
4.5 KiB
C#
113 lines
4.5 KiB
C#
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Outbox
|
|
{
|
|
public interface IOutboxPublisher
|
|
{
|
|
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
|
|
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
|
|
Task PublishProfile(LocalActor actor, CancellationToken token);
|
|
}
|
|
|
|
public class OutboxPublisher : IOutboxPublisher
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IDeliveryService _delivery;
|
|
|
|
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_delivery = delivery;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
|
|
{
|
|
if (post.Visibility == PostVisibility.LocalGeo || post.IsLocalOnly)
|
|
return Array.Empty<string>();
|
|
if (post.Visibility == PostVisibility.Circle)
|
|
return await CircleMembers(post.GroupId, token);
|
|
|
|
var inboxes = new List<string>();
|
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
|
|
inboxes.AddRange(await _delivery.FollowerInboxes(author, token));
|
|
|
|
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
|
|
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
|
|
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
foreach (var uri in addressed)
|
|
{
|
|
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
|
|
if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
|
|
inboxes.Add(actor.InboxURL);
|
|
}
|
|
|
|
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
|
|
{
|
|
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
|
|
if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL))
|
|
inboxes.Add(parentAuthor.InboxURL);
|
|
}
|
|
|
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.QuotedPostId))
|
|
{
|
|
var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token);
|
|
var quotedAuthor = quoted is { IsFederatedCopy: true }
|
|
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
|
|
: default;
|
|
if (!string.IsNullOrEmpty(quotedAuthor?.InboxURL))
|
|
inboxes.Add(quotedAuthor.InboxURL);
|
|
}
|
|
|
|
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
|
}
|
|
|
|
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token)
|
|
{
|
|
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
|
|
if (circle == default)
|
|
return Array.Empty<string>();
|
|
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
|
|
if (remote.Count == 0)
|
|
return Array.Empty<string>();
|
|
return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token))
|
|
.Select(a => a.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
|
}
|
|
|
|
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
|
|
{
|
|
var inboxes = await Audience(author, post, token);
|
|
if (inboxes.Count > 0)
|
|
await _delivery.Enqueue(author, inboxes, activity, token);
|
|
}
|
|
|
|
public async Task PublishProfile(LocalActor actor, CancellationToken token)
|
|
{
|
|
var document = ActivityPubRenderer.Actor(actor);
|
|
document.Remove("@context");
|
|
var update = new JsonObject
|
|
{
|
|
["@context"] = ActivityPubRenderer.Context(),
|
|
["id"] = actor.ActivityUri($"update-profile-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"),
|
|
["type"] = "Update",
|
|
["actor"] = actor.Uri,
|
|
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
|
["cc"] = new JsonArray(actor.Followers),
|
|
["object"] = document
|
|
};
|
|
await _delivery.EnqueueToFollowers(actor, update, token);
|
|
}
|
|
}
|
|
}
|