An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators) is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them. Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05 checks clean, 2454 of 2454. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
66 lines
2.6 KiB
C#
66 lines
2.6 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
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.
|
|
public class AccountCountsJob : IJobHandler
|
|
{
|
|
readonly IRemoteActorService _remoteActors;
|
|
readonly DbEntities _dbEntities;
|
|
readonly IFeaturedPosts _featured;
|
|
|
|
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default)
|
|
{
|
|
_remoteActors = remoteActors;
|
|
_dbEntities = dbEntities;
|
|
_featured = featured;
|
|
}
|
|
|
|
public JobKind Kind => JobKind.CountAccount;
|
|
public int Concurrency => 2;
|
|
public int MaxAttempts => 2;
|
|
public int PerHostLimit => 1;
|
|
|
|
public static string DedupeKey(string actorUri, DateTime day) => $"count|{actorUri}|{day:yyyyMMdd}";
|
|
|
|
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
|
{
|
|
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);
|
|
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)
|
|
{
|
|
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
|
|
return default;
|
|
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;
|
|
}
|
|
}
|
|
}
|