Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
353 lines
16 KiB
C#
353 lines
16 KiB
C#
using Microsoft.Extensions.Localization;
|
|
|
|
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.Social;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Resources;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Domain.Social
|
|
{
|
|
public interface IFollowService
|
|
{
|
|
Task<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token);
|
|
Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token);
|
|
Task<WebResult> Followings(string rootUserId, string avatarId, CancellationToken token);
|
|
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token);
|
|
Task UnfollowAs(LocalActor follower, string target, CancellationToken token);
|
|
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token);
|
|
Task<int> ResendPending(DateTime now, CancellationToken token);
|
|
/// <summary>An account named by its address or its handle (user@host): a persona here, or one elsewhere.</summary>
|
|
Task<(LocalActor Local, ForeignAvatar Remote)> Resolve(string target, CancellationToken token);
|
|
}
|
|
|
|
public class FollowService : IFollowService
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IRemoteActorService _remoteActors;
|
|
readonly IDeliveryService _delivery;
|
|
readonly IStringLocalizer<GenericRes> _localizer;
|
|
readonly ILogger<FollowService> _logger;
|
|
|
|
public FollowService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
|
IStringLocalizer<GenericRes> localizer, ILogger<FollowService> logger)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_remoteActors = remoteActors;
|
|
_delivery = delivery;
|
|
_localizer = localizer;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
var follower = await OwnedAvatar(rootUserId, form.AvatarId, token);
|
|
if (follower == default)
|
|
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
|
var following = await FollowAs(follower, form.Target, true, token);
|
|
if (following == default)
|
|
return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound);
|
|
result.Data = ToView(following);
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, $"{nameof(FollowService)}.{nameof(Follow)}");
|
|
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
var follower = await OwnedAvatar(rootUserId, form.AvatarId, token);
|
|
if (follower == default)
|
|
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
|
await UnfollowAs(follower, form.Target, token);
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, $"{nameof(FollowService)}.{nameof(Unfollow)}");
|
|
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
|
}
|
|
}
|
|
|
|
public async Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token)
|
|
{
|
|
var (local, remote) = await ResolveTarget(target, token);
|
|
if (local == default && remote == default)
|
|
return default;
|
|
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.IsServerActor))
|
|
return default;
|
|
|
|
var targetUri = local?.Uri ?? remote.ActorURI;
|
|
var existing = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
|
|
if (existing != default)
|
|
{
|
|
if (existing.ShowReblogs != showReblogs)
|
|
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token);
|
|
existing.ShowReblogs = showReblogs;
|
|
// a request still unanswered is sent again, at most once an hour: a server can take a Follow (202) and
|
|
// drop it later, as one that cannot yet read our actor does; one that holds it already ignores the copy
|
|
if (existing.State == FollowState.Requested && remote != default)
|
|
await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, existing), token,
|
|
again: "again-" + DateTime.UtcNow.ToString("yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture));
|
|
return existing;
|
|
}
|
|
|
|
var following = new Following
|
|
{
|
|
AvatarId = follower.Id,
|
|
FollowerKind = follower.Kind == LocalActorKind.Person ? default(LocalActorKind?) : follower.Kind,
|
|
TargetActorURI = targetUri,
|
|
TargetAccountId = local?.Id ?? remote.ID,
|
|
TargetIsLocal = local != default,
|
|
TargetInboxURL = local?.Inbox ?? remote.InboxURL,
|
|
ShowReblogs = showReblogs,
|
|
State = local is { ManuallyApprovesFollowers: false } ? FollowState.Accepted : FollowState.Requested
|
|
};
|
|
following.ID = (string)following.GenerateNewID();
|
|
following.FollowActivityURI = follower.ActivityUri($"follow-{following.ID}");
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(following, token);
|
|
}
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
return await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
|
|
}
|
|
//a new follow starts in no list, whatever lists the account was in before a follow that ended without passing here
|
|
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
|
|
|
|
if (local != default)
|
|
await FollowLocally(follower, local, following, token);
|
|
else
|
|
await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, following), token);
|
|
return following;
|
|
}
|
|
|
|
public async Task UnfollowAs(LocalActor follower, string target, CancellationToken token)
|
|
{
|
|
var (local, remote) = await ResolveTarget(target, token);
|
|
var targetUri = local?.Uri ?? remote?.ActorURI ?? target;
|
|
var following = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
|
|
if (following == default)
|
|
return;
|
|
|
|
await DB.Default.DeleteAsync<Following>(following.ID);
|
|
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
|
|
if (following.TargetIsLocal)
|
|
{
|
|
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);
|
|
await DB.Default.Update<GroupEntity>().MatchID(following.TargetAccountId)
|
|
.Modify(b => b.PullFilter(g => g.Members, m => !m.IsForeign && m.AvatarId == follower.Id && m.Role == GroupRole.Member))
|
|
.ExecuteAsync(token);
|
|
return;
|
|
}
|
|
var undo = new JsonObject
|
|
{
|
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
|
["id"] = follower.ActivityUri($"undo-follow-{following.ID}"),
|
|
["type"] = "Undo",
|
|
["actor"] = follower.Uri,
|
|
["object"] = FollowActivity(follower, following)
|
|
};
|
|
await _delivery.Enqueue(follower, new[] { following.TargetInboxURL }, undo, token);
|
|
}
|
|
|
|
public async Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token)
|
|
{
|
|
var localFollower = await _localActors.FindById(LocalActorKind.Person, followerAccountId, token);
|
|
var remoteFollower = localFollower == default ? await _dbEntities.ForeignAvatars.MatchID(followerAccountId).ExecuteFirstAsync(token) : default;
|
|
var followerUri = localFollower?.Uri ?? remoteFollower?.ActorURI;
|
|
if (followerUri == default)
|
|
return false;
|
|
var request = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.LocalActorKind == me.Kind && f.ActorURI == followerUri && !f.IsAccepted)
|
|
.ExecuteFirstAsync(token);
|
|
if (request == default)
|
|
return false;
|
|
|
|
if (accept)
|
|
await DB.Default.Update<Follower>().MatchID(request.ID).Modify(f => f.IsAccepted, true).ExecuteAsync(token);
|
|
else
|
|
await DB.Default.DeleteAsync<Follower>(request.ID);
|
|
|
|
if (localFollower != default)
|
|
{
|
|
if (accept)
|
|
await DB.Default.Update<Following>().Match(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri)
|
|
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
|
else
|
|
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri);
|
|
}
|
|
else
|
|
{
|
|
var follow = new JsonObject
|
|
{
|
|
["id"] = request.FollowActivityURI,
|
|
["type"] = "Follow",
|
|
["actor"] = followerUri,
|
|
["object"] = me.Uri
|
|
};
|
|
var answer = accept
|
|
? ActivityPubRenderer.Accept(me, follow, $"accept-{request.ID}-{DateTime.UtcNow.Ticks}")
|
|
: new JsonObject
|
|
{
|
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
|
["id"] = me.ActivityUri($"reject-{request.ID}-{DateTime.UtcNow.Ticks}"),
|
|
["type"] = "Reject",
|
|
["actor"] = me.Uri,
|
|
["object"] = follow
|
|
};
|
|
await _delivery.Enqueue(me, new[] { request.InboxURL }, answer, token);
|
|
}
|
|
if (accept)
|
|
await Notifications.Add(me.Id, NotificationType.Follow, followerAccountId, followerUri, default, token);
|
|
return true;
|
|
}
|
|
|
|
public async Task<WebResult> Followings(string rootUserId, string avatarId, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
var follower = await OwnedAvatar(rootUserId, avatarId, token);
|
|
if (follower == default)
|
|
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
|
result.Data = (await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id).Sort(f => f.ID, Order.Descending).ExecuteAsync(token))
|
|
.Select(ToView).ToList();
|
|
return result;
|
|
}
|
|
|
|
async Task FollowLocally(LocalActor follower, LocalActor target, Following following, CancellationToken token)
|
|
{
|
|
await DB.Default.Update<Follower>()
|
|
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.Uri)
|
|
.Modify(f => f.InboxURL, follower.Inbox)
|
|
.Modify(f => f.SharedInboxURL, follower.SharedInbox)
|
|
.Modify(f => f.FollowActivityURI, following.FollowActivityURI)
|
|
.Modify(f => f.IsAccepted, following.State == FollowState.Accepted)
|
|
.Modify(b => b.SetOnInsert(f => f.CreationDate, DateTime.UtcNow))
|
|
.Option(o => o.IsUpsert = true)
|
|
.ExecuteAsync(token);
|
|
|
|
if (target.Kind == LocalActorKind.Group && following.State == FollowState.Accepted)
|
|
await DB.Default.Update<GroupEntity>()
|
|
.Match(g => g.ID == target.Id && !g.Members.Any(m => !m.IsForeign && m.AvatarId == follower.Id))
|
|
.Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = follower.Id }))
|
|
.ExecuteAsync(token);
|
|
if (target.Kind == LocalActorKind.Person)
|
|
await Notifications.Add(target.Id, following.State == FollowState.Accepted ? NotificationType.Follow : NotificationType.FollowRequest,
|
|
follower.Id, follower.Uri, default, token);
|
|
}
|
|
|
|
public Task<(LocalActor Local, ForeignAvatar Remote)> Resolve(string target, CancellationToken token) => ResolveTarget(target, token);
|
|
|
|
async Task<(LocalActor Local, ForeignAvatar Remote)> ResolveTarget(string target, CancellationToken token)
|
|
{
|
|
target = target?.Trim();
|
|
if (string.IsNullOrEmpty(target))
|
|
return default;
|
|
if (target.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || target.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var byUri = await _localActors.FindByUri(target, token);
|
|
return byUri != default ? (byUri, default) : (default, await _remoteActors.GetActor(target, refresh: false, token));
|
|
}
|
|
|
|
var parts = target.TrimStart('@').Split('@');
|
|
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
|
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
|
return (await _localActors.FindByUserName(parts[0], token), default);
|
|
|
|
var actorUri = await _remoteActors.ResolveHandle($"{parts[0]}@{parts[1]}", token);
|
|
return (default, actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token));
|
|
}
|
|
|
|
// how long after it was made, or last sent again, a request still unanswered is sent again: a server can take a Follow and
|
|
// lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started sending there), and one that
|
|
// already holds the follow answers the copy again
|
|
public static readonly TimeSpan[] ResendAfter =
|
|
{
|
|
TimeSpan.FromMinutes(15), TimeSpan.FromHours(1), TimeSpan.FromHours(6), TimeSpan.FromDays(1), TimeSpan.FromDays(2), TimeSpan.FromDays(4)
|
|
};
|
|
|
|
// the requests to remote accounts due to be sent again; how many were
|
|
public async Task<int> ResendPending(DateTime now, CancellationToken token)
|
|
{
|
|
var sent = 0;
|
|
// (a request older than the count has no Resent yet: "not at least" matches it, "less than" would not)
|
|
var pending = await _dbEntities.Followings.Match(f => f.State == FollowState.Requested && !f.TargetIsLocal && !(f.Resent >= ResendAfter.Length))
|
|
.ExecuteAsync(token);
|
|
foreach (var following in pending.Where(f => (f.ResentAt ?? f.CreatedAt) + ResendAfter[f.Resent] <= now))
|
|
{
|
|
var follower = await _localActors.FindById(following.FollowerKind ?? LocalActorKind.Person, following.AvatarId, token);
|
|
var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL;
|
|
if (follower != default && !string.IsNullOrEmpty(inbox))
|
|
{
|
|
// under a new id each time: Lemmy keeps the ids it received and never answers one again
|
|
var again = FollowActivity(follower, following);
|
|
again["id"] = AgainId(following.FollowActivityURI, following.Resent + 1);
|
|
await _delivery.Enqueue(follower, new[] { inbox }, again, token,
|
|
again: "resend-" + now.ToString("yyyyMMddHHmm", System.Globalization.CultureInfo.InvariantCulture));
|
|
sent++;
|
|
}
|
|
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Inc(f => f.Resent, 1)).Modify(f => f.ResentAt, now)
|
|
.ExecuteAsync(token);
|
|
}
|
|
return sent;
|
|
}
|
|
|
|
// a follow sent again is the same follow under a new id, which an answer may name (AcceptHandler.FindFollowing)
|
|
public const string Again = "-again-";
|
|
|
|
public static string AgainId(string followActivityUri, int resend) => $"{followActivityUri}{Again}{resend}";
|
|
|
|
static JsonObject FollowActivity(LocalActor follower, Following following) => new()
|
|
{
|
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
|
["id"] = following.FollowActivityURI,
|
|
["type"] = "Follow",
|
|
["actor"] = follower.Uri,
|
|
["object"] = following.TargetActorURI
|
|
};
|
|
|
|
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
|
|
return default;
|
|
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
|
|
return default;
|
|
return await _localActors.FindById(LocalActorKind.Person, avatarId, token);
|
|
}
|
|
|
|
static ViewFollowing ToView(Following following) => new()
|
|
{
|
|
Id = following.ID,
|
|
TargetActorURI = following.TargetActorURI,
|
|
TargetAccountId = following.TargetAccountId,
|
|
TargetIsLocal = following.TargetIsLocal,
|
|
State = following.State.ToString(),
|
|
CreatedAt = following.CreatedAt
|
|
};
|
|
}
|
|
}
|