Files
SocialPub/PrivaPub/Federation/Inbox/Handlers/MoveHandler.cs
T
thepraandClaude Opus 5.5 7b0377c85f Moves carry follows over; first DMs to Lemmy 0.19 and Mbin go as ChatMessage
Two owner decisions of 2026-10-05, both recorded in ROADMAP:
- After a verified Move the personas following the old account follow the new one, in the same lists, and a mute or
  block of the old account carries over, as Mastodon does it.
- A direct message to one account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a ChatMessage,
  the one place PrivaPub decides by a server's software (invariant 17). G-0008 is closed.

Mbin addresses its private messages to the recipient's profile page, so a Create addressed to a persona's /@name now
reaches the persona. Checked live: moves 8/8, Lemmy 0.19 30/30, Mbin 26/26 with messages both ways. The software
theory runs alone, since every test's peer shares 127.0.0.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 20:57:43 +02:00

105 lines
4.3 KiB
C#

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<ForeignAvatar>().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<PersonaListMember>().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<Mute>().Match(m => m.TargetActorURI == old.ActorURI).ExecuteAsync(token))
if (await _localActors.FindById(LocalActorKind.Person, mute.AvatarId, token) is { } persona
&& !await DB.Default.Find<Mute>().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<Block>().Match(b => b.TargetActorURI == old.ActorURI).ExecuteAsync(token))
if (await _localActors.FindById(LocalActorKind.Person, block.AvatarId, token) is { } persona
&& !await DB.Default.Find<Block>().Match(b => b.AvatarId == persona.Id && b.TargetActorURI == moved.ActorURI).ExecuteAnyAsync(token))
await _relationships.Block(persona, moved.ActorURI, moved.ID, token);
}
}
}