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
This commit is contained in:
1 parent
f3e8cce2ed
commit
7b0377c85f
19 files changed
+309
-63
No files matched your search
@@ -248,9 +248,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
.Concat(post.Mentions)
|
||||
.DistinctBy(m => m.ActorURI)
|
||||
.ToList();
|
||||
// to someone who writes to us as ChatMessages (Lemmy 0.19 and Mbin take a private message no other way), in kind
|
||||
post.AsChatMessage = recipients is [{ LocalId: null } recipient]
|
||||
&& await _dbEntities.Posts.Match(p => p.ActorURI == recipient.Uri && p.IsFederatedCopy && p.ObjectType == "ChatMessage").ExecuteAnyAsync(token);
|
||||
// to one account elsewhere, as a ChatMessage when it writes to us that way, or when its server takes a private message
|
||||
// no other way (Lemmy 0.19 and Mbin)
|
||||
post.AsChatMessage = recipients is [{ LocalId: null } recipient] && (await TakesOnlyChatMessages(recipient.Uri, token)
|
||||
|| await _dbEntities.Posts.Match(p => p.ActorURI == recipient.Uri && p.IsFederatedCopy && p.ObjectType == "ChatMessage").ExecuteAnyAsync(token));
|
||||
var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI);
|
||||
create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
|
||||
post.To = Strings(note["to"]);
|
||||
@@ -547,6 +548,23 @@ namespace PrivaPub.Domain.Statuses
|
||||
|
||||
sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox);
|
||||
|
||||
// The one place PrivaPub decides by a server's software (owner decision 2026-10-05, G-0008): Lemmy before 1.0 and
|
||||
// Mbin answer a direct Note 400 or drop it, and their actors say nothing of what they take, so the first message to
|
||||
// one of their accounts goes as a ChatMessage, as NodeInfo names them
|
||||
async Task<bool> TakesOnlyChatMessages(string actorUri, CancellationToken token)
|
||||
{
|
||||
if (!Uri.TryCreate(actorUri, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
var host = uri.Host;
|
||||
var instance = await DB.Default.Find<Models.Jobs.RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
|
||||
return instance?.Software?.ToLowerInvariant() switch
|
||||
{
|
||||
"mbin" => true,
|
||||
"lemmy" => instance.SoftwareVersion is { } version && version.StartsWith("0.", StringComparison.Ordinal),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
sealed record ConversationResult(DmGroup Group, IReadOnlyList<Recipient> Recipients, int Status = StatusCodes.Status200OK, string Error = default)
|
||||
{
|
||||
public bool Ok => Error == default;
|
||||
|
||||
@@ -65,6 +65,7 @@ namespace PrivaPub.Federation.Actors
|
||||
Task<GoneActor> Gone(string userName, CancellationToken token);
|
||||
Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
|
||||
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
|
||||
Task<LocalActor> FindByAddress(string address, CancellationToken token);
|
||||
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
||||
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
|
||||
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
|
||||
@@ -153,6 +154,16 @@ namespace PrivaPub.Federation.Actors
|
||||
return FindByUserName(userName, token);
|
||||
}
|
||||
|
||||
// an actor id, or the profile page its document names as `url`: Mbin addresses its private messages to that page
|
||||
public Task<LocalActor> FindByAddress(string address, CancellationToken token)
|
||||
{
|
||||
var profile = $"{BaseAddress}/@";
|
||||
if (string.IsNullOrEmpty(address) || !address.StartsWith(profile, StringComparison.OrdinalIgnoreCase))
|
||||
return FindByUri(address, token);
|
||||
var userName = address[profile.Length..];
|
||||
return userName.Length == 0 || userName.IndexOfAny(['/', '#', '?']) >= 0 ? Task.FromResult<LocalActor>(default) : FindByUserName(userName, token);
|
||||
}
|
||||
|
||||
public async Task<LocalActor> GetInstanceActor(CancellationToken token)
|
||||
{
|
||||
var instance = _instanceActor ??= await LoadInstanceActor(token);
|
||||
|
||||
@@ -133,12 +133,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var localTargets = new List<LocalActor>();
|
||||
foreach (var uri in addressed)
|
||||
for (var i = 0; i < addressed.Count; i++)
|
||||
{
|
||||
var local = await _localActors.FindByUri(uri, token);
|
||||
if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id))
|
||||
var local = await _localActors.FindByAddress(addressed[i], token);
|
||||
if (local == default)
|
||||
continue;
|
||||
addressed[i] = local.Uri;
|
||||
if (local.IsFederated && localTargets.All(l => l.Id != local.Id))
|
||||
localTargets.Add(local);
|
||||
}
|
||||
addressed = addressed.Distinct(StringComparer.Ordinal).ToList();
|
||||
var persons = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
|
||||
|
||||
var parent = string.IsNullOrEmpty(note.InReplyTo)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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;
|
||||
|
||||
@@ -11,15 +17,25 @@ 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`). The personas following it keep
|
||||
// following it: following the new one is theirs to do.
|
||||
// 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)
|
||||
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";
|
||||
@@ -44,7 +60,45 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ namespace PrivaPub.Models.Jobs
|
||||
public DateTime? LastFailureAt { get; set; }
|
||||
public string LastError { get; set; }
|
||||
|
||||
public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour
|
||||
public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour, but for the one
|
||||
//owner decision (2026-10-05) that a first private message to Lemmy before 1.0 or Mbin is a ChatMessage
|
||||
public string SoftwareVersion { get; set; }
|
||||
public string NodeName { get; set; }
|
||||
public List<string> Protocols { get; set; } = new();
|
||||
|
||||
Reference in new issue
Block a user