R8 the Mastodon API made real: featured tags, endorsements, notes, notify and languages, suggestions, the direct stream

Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 08:38:11 +02:00
1 parent d08fbabaa5
commit 87529bca49
42 files changed
+1505 -77

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Social
{
// Follow suggestions (completion program R8, Mastodon's /api/v1 and /api/v2/suggestions), best first:
// - staff: the accounts the administrators pick (ServerSettings.StaffPicks), Mastodon's `featured`;
// - past interactions: accounts the persona favourited, boosted or answered and does not follow;
// - global: accounts followed by at least GlobalMinimumFollowers personas here (`most_followed`), and personas here whose
// public posts of the last 30 days were favourited and boosted the most (`most_interactions`); discoverable ones only.
// Never the persona itself, never a sibling persona of its login (suggesting one would pair them the moment the persona
// followed it; the server knows the pair, the persona's suggestions must not use it), never an account it follows, asked
// to follow, blocked, muted, was blocked by or dismissed, and never an account held back (suspended, a banned login's) or
// silenced (the API mapper shows it `limited`). Nothing here is shown to anyone but the persona asking.
public static class Suggestions
{
public const string Staff = "staff";
public const string PastInteractions = "past_interactions";
public const string Global = "global";
public const int GlobalMinimumFollowers = 3;
const int Recent = 200;
const int GlobalSize = 100;
static readonly TimeSpan GlobalLifetime = TimeSpan.FromMinutes(10);
static (DateTime At, List<(string Id, string Why)> Ids) _global = (DateTime.MinValue, new());
public sealed record Candidate(string AccountId, string Source, List<string> Sources);
// every candidate for the persona, in order, each once (with every reason it has), none of those it must never see
public static async Task<List<Candidate>> For(LocalActor me, IReadOnlyList<string> staffPicks, CancellationToken token)
{
var excluded = await Excluded(me, token);
var found = new List<Candidate>();
var byId = new Dictionary<string, Candidate>(StringComparer.Ordinal);
void Add(string id, string source, string why)
{
if (string.IsNullOrEmpty(id) || excluded.Contains(id))
return;
if (byId.TryGetValue(id, out var known))
{
if (!known.Sources.Contains(why))
known.Sources.Add(why);
return;
}
var candidate = new Candidate(id, source, new List<string> { why });
byId[id] = candidate;
found.Add(candidate);
}
foreach (var id in staffPicks ?? Array.Empty<string>())
Add(id, Staff, "featured");
foreach (var id in await Interacted(me.Id, token))
Add(id, PastInteractions, PastInteractions);//(Mastodon's sources has no word for it since 4.3; its source does)
foreach (var (id, why) in await GlobalCandidates(token))
Add(id, Global, why);
return found;
}
static async Task<HashSet<string>> Excluded(LocalActor me, CancellationToken token)
{
var excluded = new HashSet<string>(StringComparer.Ordinal) { me.Id };
var roots = (await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == me.Id).ExecuteAsync(token)).Select(r => r.RootId).ToList();
if (roots.Count > 0)
excluded.UnionWith((await DB.Default.Find<RootToAvatar>().Match(r => roots.Contains(r.RootId)).ExecuteAsync(token)).Select(r => r.AvatarId));
excluded.UnionWith((await DB.Default.Find<Following>().Match(f => f.AvatarId == me.Id).Project(f => f.Include(x => x.TargetAccountId))
.ExecuteAsync(token)).Select(f => f.TargetAccountId));
excluded.UnionWith((await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id).ExecuteAsync(token)).Select(b => b.TargetAccountId));
// (a persona here that blocked this one)
excluded.UnionWith((await DB.Default.Find<Block>().Match(b => b.TargetActorURI == me.Uri).ExecuteAsync(token)).Select(b => b.AvatarId));
excluded.UnionWith((await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == me.Id).ExecuteAsync(token)).Select(b => b.AccountId));
excluded.UnionWith((await DB.Default.Find<Mute>().Match(m => m.AvatarId == me.Id).ExecuteAsync(token)).Select(m => m.TargetAccountId));
excluded.UnionWith((await DB.Default.Find<SuggestionDismissal>().Match(d => d.AvatarId == me.Id).ExecuteAsync(token)).Select(d => d.AccountId));
excluded.UnionWith(await Counted.HeldBackPersonas(token));
excluded.Remove(default);
return excluded;
}
// the accounts whose posts the persona favourited, boosted or answered lately, the most often first
static async Task<List<string>> Interacted(string avatarId, CancellationToken token)
{
var authors = new List<(string Id, int Age)>();
var liked = (await DB.Default.Find<Favourite>().Match(f => f.AccountId == avatarId).Sort(f => f.ID, Order.Descending).Limit(Recent)
.ExecuteAsync(token)).Select(f => f.PostId).ToList();
var boosted = (await DB.Default.Find<PostEntity>().Match(p => p.AuthorAccountId == avatarId && p.ReblogOfPostId != null && !p.DeletedAt.HasValue)
.Sort(p => p.ID, Order.Descending).Limit(Recent).Project(p => p.Include(x => x.ReblogOfPostId)).ExecuteAsync(token)).Select(p => p.ReblogOfPostId).ToList();
var interacted = liked.Concat(boosted).Distinct().ToList();
var posts = interacted.Count == 0 ? new Dictionary<string, PostEntity>()
: (await DB.Default.Find<PostEntity>().Match(p => interacted.Contains(p.ID))
.Project(p => p.Include(x => x.AuthorAccountId).Include(x => x.GroupUserId).Include(x => x.ReblogOfPostId)).ExecuteAsync(token))
.ToDictionary(p => p.ID);
authors.AddRange(interacted.Select((id, age) => (posts.GetValueOrDefault(id) is { ReblogOfPostId: null } post ? post.AuthorAccountId ?? post.GroupUserId : default, age)));
var answered = await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && p.InReplyToAccountId != null && !p.DeletedAt.HasValue)
.Sort(p => p.ID, Order.Descending).Limit(Recent).Project(p => p.Include(x => x.InReplyToAccountId)).ExecuteAsync(token);
authors.AddRange(answered.Select((p, age) => (p.InReplyToAccountId, age)));
return authors.Where(a => !string.IsNullOrEmpty(a.Id) && a.Id != avatarId)
.GroupBy(a => a.Id).OrderByDescending(g => g.Count()).ThenBy(g => g.Min(a => a.Age)).Select(g => g.Key).ToList();
}
// the same for every persona, worked out at most every ten minutes
static async Task<List<(string Id, string Why)>> GlobalCandidates(CancellationToken token)
{
var cached = _global;
if (DateTime.UtcNow - cached.At < GlobalLifetime)
return cached.Ids;
var ids = new List<(string, string)>();
var followed = await DB.Default.Fluent<Following>()
.Match(f => f.State == FollowState.Accepted && f.FollowerKind == null)
.Group(f => f.TargetAccountId, g => new { Id = g.Key, Count = g.Count() })
.Match(g => g.Count >= GlobalMinimumFollowers)
.SortByDescending(g => g.Count)
.Limit(GlobalSize)
.ToListAsync(token);
var followedIds = followed.Select(f => f.Id).ToList();
var discoverable = (await DB.Default.Find<Avatar>().Match(a => followedIds.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue
&& !a.SilencedAt.HasValue && !a.SuspendedAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID)
.Concat((await DB.Default.Find<ForeignAvatar>().Match(f => followedIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue
&& !f.SilencedAt.HasValue && !f.SuspendedAt.HasValue
&& (f.AvatarType == AvatarType.Person || f.AvatarType == AvatarType.Service || f.AvatarType == AvatarType.Organization))
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID))
.ToHashSet(StringComparer.Ordinal);
ids.AddRange(followed.Where(f => discoverable.Contains(f.Id)).Select(f => (f.Id, "most_followed")));
var since = DateTime.UtcNow.AddDays(-30);
var interactions = await DB.Default.Fluent<PostEntity>()
.Match(p => !p.IsFederatedCopy && p.GroupUserId != null && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null
&& !p.DeletedAt.HasValue && !p.AuthorLimited && !p.AuthorSuspended && p.CreationDate >= since)
.Group(p => p.GroupUserId, g => new { Id = g.Key, Count = g.Sum(p => p.FavouritesCount + p.ReblogsCount) })
.Match(g => g.Count > 0)
.SortByDescending(g => g.Count)
.Limit(GlobalSize)
.ToListAsync(token);
var active = interactions.Select(i => i.Id).ToList();
var shown = (await DB.Default.Find<Avatar>().Match(a => active.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue
&& !a.SilencedAt.HasValue && !a.SuspendedAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
ids.AddRange(interactions.Where(i => shown.Contains(i.Id)).Select(i => (i.Id, "most_interactions")));
_global = (DateTime.UtcNow, ids);
return ids;
}
}
}