using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Domain.Relationships; using PrivaPub.Domain.Social; using PrivaPub.Federation.Actors; using PrivaPub.Models.Federation; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Text.Json.Nodes; using static PrivaPub.Federation.Objects.ActivityJson; namespace PrivaPub.Federation.Inbox.Handlers { // An account moving to another (Mastodon's Move{object: itself, target: the new account}), believed as Mastodon // believes it: the moving account sends it about itself, and the new account, read again from its own server, names // it among its alsoKnownAs. The old account then shows where it went (`moved`), and, as Mastodon does it (owner // decision 2026-10-05), the personas following it follow the new one instead, in the same lists, and those that // muted or blocked it mute or block the new one too. public class MoveHandler : IActivityHandler { readonly IRemoteActorService _remoteActors; readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IFollowService _follows; readonly IRelationshipService _relationships; public MoveHandler(IRemoteActorService remoteActors, DbEntities dbEntities, ILocalActorService localActors, IFollowService follows, IRelationshipService relationships) { _remoteActors = remoteActors; _dbEntities = dbEntities; _localActors = localActors; _follows = follows; _relationships = relationships; } public string Type => "Move"; public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) { var target = Id(activity["target"]); if (Id(activity["object"]) != actor.ActorURI || string.IsNullOrEmpty(target) || target == actor.ActorURI) { Arrival.Drop("not-self"); return; } var moved = await _remoteActors.GetActor(target, refresh: true, token); if (moved == default) { Arrival.Drop("target-unavailable"); return; } if (!moved.AlsoKnownAs.Contains(actor.ActorURI)) { Arrival.Drop("not-aliased"); return; } await DB.Default.Update().MatchID(actor.ID).Modify(a => a.MovedToURL, target).ExecuteAsync(token); await MoveFollows(actor, moved, token); await CarryOver(actor, moved, token); Arrival.Accept("moved"); } async Task MoveFollows(ForeignAvatar old, ForeignAvatar moved, CancellationToken token) { foreach (var following in await _dbEntities.Followings.Match(f => f.TargetActorURI == old.ActorURI).ExecuteAsync(token)) { var persona = await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token); if (persona == default) continue; var lists = await DB.Default.Find().Match(m => m.AvatarId == persona.Id && m.AccountId == old.ID).ExecuteAsync(token); var followed = await _follows.FollowAs(persona, moved.ActorURI, following.ShowReblogs, token); await _follows.UnfollowAs(persona, old.ActorURI, token); if (followed == default) continue; foreach (var member in lists) try { await DB.Default.SaveAsync(new PersonaListMember { ListId = member.ListId, AvatarId = persona.Id, AccountId = moved.ID }, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { } } } async Task CarryOver(ForeignAvatar old, ForeignAvatar moved, CancellationToken token) { foreach (var mute in await DB.Default.Find().Match(m => m.TargetActorURI == old.ActorURI).ExecuteAsync(token)) if (await _localActors.FindById(LocalActorKind.Person, mute.AvatarId, token) is { } persona && !await DB.Default.Find().Match(m => m.AvatarId == persona.Id && m.TargetActorURI == moved.ActorURI).ExecuteAnyAsync(token)) await _relationships.Mute(persona, moved.ActorURI, moved.ID, mute.HideNotifications, mute.ExpiresAt is { } expires ? expires - DateTime.UtcNow : default(TimeSpan?), token); foreach (var block in await DB.Default.Find().Match(b => b.TargetActorURI == old.ActorURI).ExecuteAsync(token)) if (await _localActors.FindById(LocalActorKind.Person, block.AvatarId, token) is { } persona && !await DB.Default.Find().Match(b => b.AvatarId == persona.Id && b.TargetActorURI == moved.ActorURI).ExecuteAnyAsync(token)) await _relationships.Block(persona, moved.ActorURI, moved.ID, token); } } }