One Post model for every visibility, read from notes the way peers write them

Post gains what federation and the Mastodon API need: Visibility (Public,
Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account
id, to/cc, the Create's id, url, context, quote, InReplyToURI and the
parent's author, a separate SpoilerText next to the title, language,
mentions, hashtags, remote attachments (alt text, blurhash, focus, size),
reply/favourite/reblog counters, revisions, EditedAt and DeletedAt.

Direct messages are Posts with Visibility Direct and a ConversationId;
migration _005 copies DmPost rows across with their ids and fills the new
fields of existing posts. DmPost is left in place so a rollback still sees
the old messages.

Inbound:
- NoteParser reads Note, Article, Page, Question and media types: content,
  then contentMap, then _misskey_content; summary as the spoiler and name
  as the title; Mention and Hashtag tags; attachments; a PeerTube-style
  list attribution prefers the person over the channel; quote URIs.
- Addressing classifies like Mastodon, finding followers-only by the
  author's own followers URL (now stored on ForeignAvatar), not by a
  "/followers" suffix.
- Create keeps a post when a local persona is addressed or mentioned, when
  it replies to a local post (the parent's reply count goes up) or when a
  community it follows is addressed; an unsolicited public post is not
  stored. Update keeps the previous version as a revision.

The outbox and object endpoints serve only Public and Unlisted posts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:17:14 +02:00
1 parent 796f06acf4
commit e9529310fe
21 files changed
+777 -192

No files matched your search

+2
View File
@@ -18,6 +18,8 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
await Plain<Post>(token, p => p.GroupId, p => p.ID);
await Plain<Post>(token, p => p.ActorURI);
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
await Plain<Post>(token, p => p.InReplyToURI);
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
@@ -0,0 +1,67 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _005_direct_posts_join_posts : IMigration
{
public async Task UpgradeAsync()
{
foreach (var dm in await DB.Default.Find<DmPost>().ExecuteAsync())
{
if (await DB.Default.Find<Post>().Match(p => p.ID == dm.ID || p.ObjectURI == dm.ObjectURI).ExecuteAnyAsync())
continue;
await DB.Default.SaveAsync(new Post
{
ID = dm.ID,
GroupUserId = dm.GroupUserId,
AuthorAccountId = dm.IsFederatedCopy ? await ForeignAccountId(dm.ActorURI) : dm.GroupUserId,
ConversationId = dm.GroupId,
Visibility = PostVisibility.Direct,
AnsweringToPostId = Local(dm.AnsweringToPostId),
InReplyToURI = Remote(dm.AnsweringToPostId),
Title = dm.Title,
Text = dm.Text,
ContentHtml = dm.ContentHtml,
ContentFormat = dm.ContentFormat,
Media = dm.Media,
HasContentWarning = dm.HasContentWarning,
IsFederatedCopy = dm.IsFederatedCopy,
ObjectURI = dm.ObjectURI,
ActorURI = dm.ActorURI,
Url = dm.ObjectURI,
CreationDate = dm.CreationDate,
UpdateDate = dm.UpdateDate
});
}
foreach (var post in await DB.Default.Find<Post>().Match(p => p.Visibility != PostVisibility.Direct).ExecuteAsync())
{
var parent = Local(post.AnsweringToPostId) is { } parentId
? await DB.Default.Find<Post>().OneAsync(parentId)
: Remote(post.AnsweringToPostId) is { } parentUri
? await DB.Default.Find<Post>().Match(p => p.ObjectURI == parentUri).ExecuteFirstAsync()
: default;
await DB.Default.Update<Post>().MatchID(post.ID)
.Modify(p => p.Visibility, post.IsLocalOnly ? PostVisibility.Circle : PostVisibility.Public)
.Modify(p => p.AuthorAccountId, post.IsFederatedCopy ? await ForeignAccountId(post.ActorURI) : post.GroupUserId)
.Modify(p => p.AnsweringToPostId, parent?.ID)
.Modify(p => p.InReplyToURI, parent?.ObjectURI ?? Remote(post.AnsweringToPostId))
.Modify(p => p.InReplyToAccountId, parent?.AuthorAccountId ?? parent?.GroupUserId)
.Modify(p => p.Url, post.Url ?? post.ObjectURI)
.ExecuteAsync();
}
}
static string Local(string reference) =>
string.IsNullOrEmpty(reference) || reference.Contains("://") ? default : reference;
static string Remote(string reference) =>
reference != default && reference.Contains("://") ? reference : default;
static async Task<string> ForeignAccountId(string actorUri) =>
(await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync())?.ID;
}
}