Lemmy 1.0's multi-communities and PieFed's feeds (`type: Feed`) resolve as accounts: the feed's `following` is kept
once a day with its counts, each community in it is read in turn, and `/api/v1/accounts/:id/following` lists them.
Lemmy's feed carries the instance's key and its text in `description`; both are taken as they are. Following a feed
answers 422 until the owner decides how a feed is followed.
A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
goes there with its thread; a move into a community we host is not taken.
Checked in the pasture: a Lemmy 1.0 feed and a PieFed feed list their communities (ours among them); a PieFed post moved
from its web code arrives moved, with its comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
62189bf11d
commit
3b3f1f6b93
10 files changed
+226
-25
No files matched your search
@@ -12,18 +12,23 @@ namespace PrivaPub.Federation.Actors
|
||||
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its
|
||||
// followers, following and outbox collections), and the posts it features. Read when the account is fetched, at most
|
||||
// once a day, never when someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and
|
||||
// GoToSocial do the same.
|
||||
// GoToSocial do the same. A feed's following is its communities: they are kept, and read in turn, so its list shows.
|
||||
public class AccountCountsJob : IJobHandler
|
||||
{
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IFeaturedPosts _featured;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default)
|
||||
public const int MaxFeedCommunities = 50;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default,
|
||||
ILocalActorService localActors = default)
|
||||
{
|
||||
_remoteActors = remoteActors;
|
||||
_dbEntities = dbEntities;
|
||||
_featured = featured;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.CountAccount;
|
||||
@@ -38,28 +43,52 @@ namespace PrivaPub.Federation.Actors
|
||||
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == job.Payload && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (actor == default)
|
||||
return JobOutcome.Done;
|
||||
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, await Total(actor, actor.FollowersURL, token))
|
||||
.Modify(a => a.FollowingCount, await Total(actor, actor.FollowingURL, token))
|
||||
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
var feed = actor.AvatarType == AvatarType.Feed;
|
||||
var (following, communities) = await Read(actor, actor.FollowingURL, feed, token);
|
||||
var update = DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, (await Read(actor, actor.FollowersURL, false, token)).Total)
|
||||
.Modify(a => a.FollowingCount, following)
|
||||
.Modify(a => a.StatusesCount, (await Read(actor, actor.OutboxURL, false, token)).Total)
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow);
|
||||
if (feed)
|
||||
update = update.Modify(a => a.FeedCommunities, communities);
|
||||
await update.ExecuteAsync(token);
|
||||
if (feed)
|
||||
foreach (var community in communities)
|
||||
if (_localActors == default || !community.StartsWith(_localActors.BaseAddress + "/", StringComparison.Ordinal))
|
||||
await _remoteActors.GetActor(community, refresh: false, token);
|
||||
if (_featured != default)
|
||||
await _featured.Sync(actor, token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
// only from the actor's own server, as everything we believe about it
|
||||
async Task<int?> Total(ForeignAvatar actor, string collection, CancellationToken token)
|
||||
// only from the actor's own server, as everything we believe about it: its total, and the ids it lists when asked
|
||||
// (inline, or on its first page)
|
||||
async Task<(int? Total, List<string> Items)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token)
|
||||
{
|
||||
var listed = new List<string>();
|
||||
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
|
||||
return default;
|
||||
return (default, listed);
|
||||
using var scope = HttpScope.For("collection");
|
||||
using var fetched = await _remoteActors.FetchObject(collection, token);
|
||||
return fetched != default && fetched.Root.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& fetched.Root.TryGetProperty("totalItems", out var total) && total.TryGetInt32(out var count) && count >= 0
|
||||
? count
|
||||
: default;
|
||||
if (fetched == default || fetched.Root.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
return (default, listed);
|
||||
int? total = fetched.Root.TryGetProperty("totalItems", out var counted) && counted.TryGetInt32(out var count) && count >= 0 ? count : default;
|
||||
if (!items)
|
||||
return (total, listed);
|
||||
var document = System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText());
|
||||
var page = document["orderedItems"] ?? document["items"] ?? document["first"]?["orderedItems"] ?? document["first"]?["items"];
|
||||
if (page == default && ActivityJson.Id(document["first"]) is { } first && Origin.Same(first, actor.ActorURI))
|
||||
{
|
||||
using var next = await _remoteActors.FetchObject(first, token);
|
||||
var nextPage = next == default ? default : System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText());
|
||||
page = nextPage?["orderedItems"] ?? nextPage?["items"];
|
||||
}
|
||||
if (page is System.Text.Json.Nodes.JsonArray array)
|
||||
listed = array.Select(ActivityJson.Id)
|
||||
.Where(id => Uri.TryCreate(id, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
|
||||
.Distinct(StringComparer.Ordinal).Take(MaxFeedCommunities).ToList();
|
||||
return (total, listed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace PrivaPub.Federation.Actors
|
||||
|
||||
public sealed class ActorDocument
|
||||
{
|
||||
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" };
|
||||
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization", "Feed" };
|
||||
|
||||
public string Id { get; init; }
|
||||
public string Type { get; init; }
|
||||
@@ -74,7 +74,8 @@ namespace PrivaPub.Federation.Actors
|
||||
Features = ActorFeatures.Detect(root),
|
||||
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
|
||||
Name = RemoteActorService.Text(root, "name"),
|
||||
Summary = RemoteActorService.Text(root, "summary"),
|
||||
// (Lemmy's feed keeps its text in `description`)
|
||||
Summary = RemoteActorService.Text(root, "summary") ?? (type == "Feed" ? RemoteActorService.Text(root, "description") : default),
|
||||
Url = RemoteActorService.Text(root, "url") ?? id,
|
||||
Inbox = Origin.Same(inbox, id) ? inbox : default,
|
||||
Outbox = RemoteActorService.Text(root, "outbox"),
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var inner = activity["object"];
|
||||
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock")
|
||||
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock" or "Move")
|
||||
{
|
||||
await GroupActivity(inner, actor, token);
|
||||
return;
|
||||
@@ -242,6 +242,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
case "Lock" when objectUri != default:
|
||||
await Lock(objectUri, group, true, token);
|
||||
break;
|
||||
case "Move" when objectUri != default && Id(inner["origin"]) == group.ActorURI:
|
||||
await Move(objectUri, Id(inner["target"]), group, token);
|
||||
break;
|
||||
case "Undo" when Value(inner["object"], "type") == "Lock":
|
||||
await Lock(Id(inner["object"]?["object"]), group, false, token);
|
||||
break;
|
||||
@@ -305,6 +308,42 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
Arrival.Accept(locked ? "locked" : "unlocked");
|
||||
}
|
||||
|
||||
// A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
|
||||
// which vouches for what is done to its own posts as for a lock; its thread goes with it. A move into a community we
|
||||
// host is not taken: it would be ours to announce.
|
||||
async Task Move(string objectUri, string targetUri, ForeignAvatar group, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
{
|
||||
Arrival.Drop("unknown-object");
|
||||
return;
|
||||
}
|
||||
Arrival.About(visibility: post.Visibility, created: post.CreationDate);
|
||||
var target = targetUri == default || targetUri == group.ActorURI || await _localActors.FindByUri(targetUri, token) != default
|
||||
? default
|
||||
: await _remoteActors.GetActor(targetUri, refresh: false, token);
|
||||
if (target is not { AvatarType: AvatarType.Group })
|
||||
{
|
||||
Arrival.Drop("unknown-target");
|
||||
return;
|
||||
}
|
||||
var thread = new List<string> { post.ID };
|
||||
var level = thread.ToList();
|
||||
while (level.Count > 0 && thread.Count < MaxMoved)
|
||||
{
|
||||
var parents = level;
|
||||
level = (await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && p.AudienceURI == group.ActorURI)
|
||||
.Project(p => new PostEntity { ID = p.ID }).ExecuteAsync(token)).Select(p => p.ID).ToList();
|
||||
thread.AddRange(level);
|
||||
}
|
||||
await DB.Default.Update<PostEntity>().Match(p => thread.Contains(p.ID)).Modify(p => p.AudienceURI, target.ActorURI).ExecuteAsync(token);
|
||||
Arrival.Accept("moved");
|
||||
}
|
||||
|
||||
const int MaxMoved = 1000;
|
||||
|
||||
// A community bans one of our personas (Lemmy's Block with the community as its target): kept as the community
|
||||
// blocking the persona, so its relationship says blocked_by and nothing of the persona's is posted there until the
|
||||
// Undo. A ban of someone from another server is no business of ours.
|
||||
|
||||
Reference in new issue
Block a user