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 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> For(LocalActor me, IReadOnlyList staffPicks, CancellationToken token) { var excluded = await Excluded(me, token); var found = new List(); var byId = new Dictionary(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 { why }); byId[id] = candidate; found.Add(candidate); } foreach (var id in staffPicks ?? Array.Empty()) 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> Excluded(LocalActor me, CancellationToken token) { var excluded = new HashSet(StringComparer.Ordinal) { me.Id }; var roots = (await DB.Default.Find().Match(r => r.AvatarId == me.Id).ExecuteAsync(token)).Select(r => r.RootId).ToList(); if (roots.Count > 0) excluded.UnionWith((await DB.Default.Find().Match(r => roots.Contains(r.RootId)).ExecuteAsync(token)).Select(r => r.AvatarId)); excluded.UnionWith((await DB.Default.Find().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().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().Match(b => b.TargetActorURI == me.Uri).ExecuteAsync(token)).Select(b => b.AvatarId)); excluded.UnionWith((await DB.Default.Find().Match(b => b.AvatarId == me.Id).ExecuteAsync(token)).Select(b => b.AccountId)); excluded.UnionWith((await DB.Default.Find().Match(m => m.AvatarId == me.Id).ExecuteAsync(token)).Select(m => m.TargetAccountId)); excluded.UnionWith((await DB.Default.Find().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> Interacted(string avatarId, CancellationToken token) { var authors = new List<(string Id, int Age)>(); var liked = (await DB.Default.Find().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().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() : (await DB.Default.Find().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().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> 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() .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().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().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() .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().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; } } }