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
257 lines
11 KiB
C#
257 lines
11 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Entities;
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Net;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Mappers
|
|
{
|
|
public class MastodonMapper
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
|
|
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
}
|
|
|
|
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
|
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
|
|
|
public async Task<Account> Local(LocalActor actor, bool withSource, CancellationToken token)
|
|
{
|
|
var followers = await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token);
|
|
var following = actor.Kind == LocalActorKind.Person
|
|
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
|
|
: 0;
|
|
var latest = await _dbEntities.Posts
|
|
.Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy
|
|
&& !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null)
|
|
.Sort(p => p.ID, Order.Descending)
|
|
.Limit(1)
|
|
.ExecuteFirstAsync(token);
|
|
var statuses = latest == default
|
|
? 0
|
|
: await DB.Default.CountAsync<PostEntity>(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id)
|
|
&& !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token);
|
|
|
|
var account = new Account
|
|
{
|
|
Id = actor.Id,
|
|
Username = actor.UserName,
|
|
Acct = actor.UserName,
|
|
DisplayName = actor.Name ?? actor.UserName,
|
|
Locked = actor.ManuallyApprovesFollowers,
|
|
Bot = actor.IsBot,
|
|
Discoverable = actor.Discoverable,
|
|
Indexable = actor.Indexable,
|
|
Noindex = !actor.Indexable,
|
|
HideCollections = actor.Settings.HideCollections,
|
|
Group = actor.Kind == LocalActorKind.Group,
|
|
CreatedAt = MastodonJson.Day(actor.Published),
|
|
Note = ActivityPubRenderer.Html(actor.Summary),
|
|
Url = actor.HtmlUrl,
|
|
Uri = actor.Uri,
|
|
Avatar = actor.PictureURL ?? MissingAvatar,
|
|
AvatarStatic = actor.PictureURL ?? MissingAvatar,
|
|
Header = actor.ThumbnailURL ?? MissingHeader,
|
|
HeaderStatic = actor.ThumbnailURL ?? MissingHeader,
|
|
FollowersCount = (int)followers,
|
|
FollowingCount = (int)following,
|
|
StatusesCount = (int)statuses,
|
|
LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"),
|
|
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList()
|
|
};
|
|
if (withSource)
|
|
account.Source = new Source
|
|
{
|
|
Privacy = actor.Settings.DefaultVisibility ?? "public",
|
|
Sensitive = actor.Settings.DefaultSensitive,
|
|
Language = actor.Settings.DefaultLanguage,
|
|
Note = actor.Summary ?? string.Empty,
|
|
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
|
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
|
};
|
|
return account;
|
|
}
|
|
|
|
public Account Foreign(ForeignAvatar foreign) => new()
|
|
{
|
|
Id = foreign.ID,
|
|
Username = foreign.UserName,
|
|
Acct = $"{foreign.UserName}@{foreign.Domain}",
|
|
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
|
|
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
|
|
Group = foreign.AvatarType == AvatarType.Group,
|
|
Discoverable = foreign.IsDiscoverable,
|
|
CreatedAt = MastodonJson.Day(foreign.CreatedAt),
|
|
Note = foreign.Biography ?? string.Empty,
|
|
Url = foreign.Url ?? foreign.ActorURI,
|
|
Uri = foreign.ActorURI,
|
|
Avatar = foreign.PictureURL ?? MissingAvatar,
|
|
AvatarStatic = foreign.PictureURL ?? MissingAvatar,
|
|
Header = foreign.ThumbnailURL ?? MissingHeader,
|
|
HeaderStatic = foreign.ThumbnailURL ?? MissingHeader
|
|
};
|
|
|
|
public async Task<Account> Account(string id, CancellationToken token) =>
|
|
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
|
|
|
|
public async Task<Dictionary<string, Account>> Accounts(IEnumerable<string> ids, CancellationToken token)
|
|
{
|
|
var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
|
|
var accounts = new Dictionary<string, Account>();
|
|
if (wanted.Count == 0)
|
|
return accounts;
|
|
|
|
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
|
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
|
|
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token))
|
|
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
|
|
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
|
|
accounts[foreign.ID] = Foreign(foreign);
|
|
return accounts;
|
|
}
|
|
|
|
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
|
|
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
|
|
|
|
public async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token)
|
|
{
|
|
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
|
|
var originals = originalIds.Count == 0
|
|
? new Dictionary<string, PostEntity>()
|
|
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
|
var all = posts.Concat(originals.Values).ToList();
|
|
|
|
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
|
|
var mentionAccounts = mentionUris.Count == 0
|
|
? new Dictionary<string, ForeignAvatar>()
|
|
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
|
|
var accounts = await Accounts(all.Select(AuthorOf), token);
|
|
|
|
var ids = all.Select(p => p.ID).ToList();
|
|
var favourited = viewerId == default
|
|
? new HashSet<string>()
|
|
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
|
|
var reblogged = viewerId == default
|
|
? new HashSet<string>()
|
|
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
|
.Select(p => p.ReblogOfPostId).ToHashSet();
|
|
|
|
Status Map(PostEntity post)
|
|
{
|
|
if (!accounts.TryGetValue(AuthorOf(post), out var account))
|
|
return default;
|
|
var status = new Status
|
|
{
|
|
Id = post.ID,
|
|
Uri = post.ObjectURI,
|
|
Url = post.Url ?? post.ObjectURI,
|
|
Account = account,
|
|
InReplyToId = post.AnsweringToPostId,
|
|
InReplyToAccountId = post.InReplyToAccountId,
|
|
Content = Content(post),
|
|
CreatedAt = MastodonJson.Time(post.CreationDate),
|
|
EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default,
|
|
RepliesCount = post.RepliesCount,
|
|
ReblogsCount = post.ReblogsCount,
|
|
FavouritesCount = post.FavouritesCount,
|
|
Favourited = favourited.Contains(post.ID),
|
|
Reblogged = reblogged.Contains(post.ID),
|
|
Sensitive = post.HasContentWarning,
|
|
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
|
|
Visibility = Visibility(post.Visibility),
|
|
Language = post.Language,
|
|
MediaAttachments = post.Media.Select(Media).ToList(),
|
|
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
|
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
|
};
|
|
return status;
|
|
}
|
|
|
|
var mapped = new List<Status>();
|
|
foreach (var post in posts)
|
|
{
|
|
var status = Map(post);
|
|
if (status == default)
|
|
continue;
|
|
if (post.ReblogOfPostId != default)
|
|
{
|
|
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
|
|
continue;
|
|
status.Reblog = inner;
|
|
status.Content = string.Empty;
|
|
status.Reblogged = reblogged.Contains(original.ID);
|
|
}
|
|
mapped.Add(status);
|
|
}
|
|
return mapped;
|
|
}
|
|
|
|
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
|
|
|
|
public static string Content(PostEntity post)
|
|
{
|
|
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
|
|
return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy
|
|
? content ?? string.Empty
|
|
: $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
|
|
}
|
|
|
|
public static string Visibility(PostVisibility visibility) => visibility switch
|
|
{
|
|
PostVisibility.Unlisted => "unlisted",
|
|
PostVisibility.FollowersOnly or PostVisibility.Circle => "private",
|
|
PostVisibility.Direct => "direct",
|
|
_ => "public"
|
|
};
|
|
|
|
StatusMention Mention(PostMention mention, IReadOnlyDictionary<string, ForeignAvatar> foreign)
|
|
{
|
|
if (mention.IsLocal)
|
|
{
|
|
var handle = mention.Handle?.TrimStart('@').Split('@')[0];
|
|
return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI };
|
|
}
|
|
if (!foreign.TryGetValue(mention.ActorURI, out var account))
|
|
return default;
|
|
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
|
|
}
|
|
|
|
static MediaAttachment Media(PostMedia media) => new()
|
|
{
|
|
Id = media.Id.ToString("N"),
|
|
Type = media.ContentType switch
|
|
{
|
|
{ } type when type.StartsWith("image/gif") => "gifv",
|
|
{ } type when type.StartsWith("image/") => "image",
|
|
{ } type when type.StartsWith("video/") => "video",
|
|
{ } type when type.StartsWith("audio/") => "audio",
|
|
_ => "unknown"
|
|
},
|
|
Url = media.URL ?? media.RemoteURL,
|
|
PreviewUrl = media.URL ?? media.RemoteURL,
|
|
RemoteUrl = media.RemoteURL,
|
|
Description = media.Description,
|
|
Blurhash = media.Blurhash,
|
|
Meta = media.Width.HasValue && media.Height.HasValue
|
|
? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } }
|
|
: default
|
|
};
|
|
}
|
|
}
|