The Mastodon client API: accounts, statuses, timelines, notifications, search
With a token for a persona, a Mastodon client can now:
- accounts: verify/update credentials (display name, note, fields, locked,
bot, discoverable, indexable, hide collections, posting defaults; the
change federates as Update{Person}), get, lookup, statuses (paged, by
visibility to the viewer), relationships, search, follow and unfollow,
follow requests (authorize and reject answer remote followers with
Accept or Reject), remove from followers. Followers and following lists
are shown to their owner only.
- statuses: post (plain text, mentions, hashtags, replies, content
warnings, the four visibilities, Idempotency-Key), get, edit (PUT),
delete returning the source for redrafting, context, history, source,
favourite, reblog and their undos, favourited_by and reblogged_by.
- timelines: home, public (local or remote), tag; favourites;
conversations; markers.
- notifications: list with types and exclude_types, get, dismiss, clear,
unread count.
- /api/v2/search, resolving a handle or a URL to an account or a post.
Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and
push answer empty lists or a 422 saying they are not supported yet, so
clients degrade instead of failing. Public reads work without a token.
Persona settings (locked, bot, indexable, discoverable) now also shape
the ActivityPub actor.
Fixed on the way: three conditional expressions whose `default` was the
value type's, so a missing limit became 1, a missing flag became false and
an attachment without dimensions became 0x0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
b54cdf78b2
commit
0028e96e76
17 files changed
+1290
-73
No files matched your search
@@ -26,6 +26,9 @@ namespace PrivaPub.Domain.Social
|
||||
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);
|
||||
}
|
||||
|
||||
public class FollowService : IFollowService
|
||||
@@ -56,47 +59,9 @@ namespace PrivaPub.Domain.Social
|
||||
var follower = await OwnedAvatar(rootUserId, form.AvatarId, token);
|
||||
if (follower == default)
|
||||
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
||||
|
||||
var (local, remote) = await ResolveTarget(form.Target, token);
|
||||
if (local == default && remote == default)
|
||||
var following = await FollowAs(follower, form.Target, true, token);
|
||||
if (following == default)
|
||||
return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound);
|
||||
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application))
|
||||
return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound);
|
||||
|
||||
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)
|
||||
{
|
||||
result.Data = ToView(existing);
|
||||
return result;
|
||||
}
|
||||
|
||||
var following = new Following
|
||||
{
|
||||
AvatarId = follower.Id,
|
||||
TargetActorURI = targetUri,
|
||||
TargetAccountId = local?.Id ?? remote.ID,
|
||||
TargetIsLocal = local != default,
|
||||
TargetInboxURL = local?.Inbox ?? remote.InboxURL,
|
||||
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)
|
||||
{
|
||||
result.Data = ToView(await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (local != default)
|
||||
await FollowLocally(follower, local, following, token);
|
||||
else
|
||||
await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, following), token);
|
||||
|
||||
result.Data = ToView(following);
|
||||
return result;
|
||||
}
|
||||
@@ -115,33 +80,7 @@ namespace PrivaPub.Domain.Social
|
||||
var follower = await OwnedAvatar(rootUserId, form.AvatarId, token);
|
||||
if (follower == default)
|
||||
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
||||
|
||||
var (local, remote) = await ResolveTarget(form.Target, token);
|
||||
var targetUri = local?.Uri ?? remote?.ActorURI ?? form.Target;
|
||||
var following = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
|
||||
if (following == default)
|
||||
return result;
|
||||
|
||||
await DB.Default.DeleteAsync<Following>(following.ID);
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
}
|
||||
await UnfollowAs(follower, form.Target, token);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -151,6 +90,131 @@ namespace PrivaPub.Domain.Social
|
||||
}
|
||||
}
|
||||
|
||||
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.Kind == LocalActorKind.Application))
|
||||
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;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var following = new Following
|
||||
{
|
||||
AvatarId = follower.Id,
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user