2026-10-01 11:55:22 +02:00
using MongoDB.Driver ;
using MongoDB.Entities ;
using PrivaPub.Domain.Content ;
2026-10-01 12:22:08 +02:00
using PrivaPub.Domain.Media ;
2026-10-01 11:55:22 +02:00
using PrivaPub.Domain.Privacy ;
using PrivaPub.Domain.Social ;
using PrivaPub.Domain.Timelines ;
using PrivaPub.Federation.Actors ;
using PrivaPub.Federation.Outbox ;
using PrivaPub.Federation.Rendering ;
using PrivaPub.Models.Federation ;
using PrivaPub.Models.Group ;
2026-10-01 12:22:08 +02:00
using PrivaPub.Models.Media ;
2026-10-01 11:55:22 +02:00
using PrivaPub.Models.Post ;
using PrivaPub.Models.Social ;
using PrivaPub.StaticServices ;
using System.Text.Json.Nodes ;
using PostEntity = PrivaPub . Models . Post . Post ;
namespace PrivaPub.Domain.Statuses
{
public sealed class StatusDraft
{
public string Text { get ; init ; }
public bool PlainText { get ; init ; }
public string Title { get ; init ; }
public string SpoilerText { get ; init ; }
public bool Sensitive { get ; init ; }
public PostVisibility Visibility { get ; init ; } = PostVisibility . Public ;
public string InReplyTo { get ; init ; }
public string GroupId { get ; init ; }
public string Language { get ; init ; }
public string ConversationId { get ; init ; }
public IReadOnlyList < string > Recipients { get ; init ; } = Array . Empty < string >();
2026-10-01 12:22:08 +02:00
public IReadOnlyList < string > MediaIds { get ; init ; }
2026-10-01 12:24:30 +02:00
public double? Latitude { get ; init ; }
public double? Longitude { get ; init ; }
public double? RangeKm { get ; init ; }
2026-10-01 11:55:22 +02:00
}
public sealed record StatusOutcome ( PostEntity Post , int Status = StatusCodes . Status200OK , string Error = default )
{
public bool Ok => Error == default ;
public static StatusOutcome Fail ( int status , string error ) => new ( default , status , error );
}
public interface IStatusService
{
Task < StatusOutcome > Publish ( LocalActor author , StatusDraft draft , CancellationToken token );
Task < StatusOutcome > Edit ( LocalActor author , string postId , StatusDraft draft , CancellationToken token );
Task < StatusOutcome > Remove ( LocalActor author , string postId , CancellationToken token );
Task < StatusOutcome > Favourite ( LocalActor me , string postId , bool on , CancellationToken token );
Task < StatusOutcome > Reblog ( LocalActor me , string postId , bool on , PostVisibility visibility , CancellationToken token );
}
public class StatusService : IStatusService
{
const int MaxRevisions = 20 ;
readonly DbEntities _dbEntities ;
readonly ILocalActorService _localActors ;
readonly IRemoteActorService _remoteActors ;
readonly IDeliveryService _delivery ;
readonly IContentRenderer _content ;
readonly IOutboxPublisher _outbox ;
readonly IFanout _fanout ;
2026-10-01 12:22:08 +02:00
readonly IMediaService _media ;
2026-10-01 12:30:57 +02:00
readonly IGroupDistributor _groups ;
2026-10-01 11:55:22 +02:00
public StatusService ( DbEntities dbEntities , ILocalActorService localActors , IRemoteActorService remoteActors , IDeliveryService delivery ,
2026-10-01 12:30:57 +02:00
IContentRenderer content , IOutboxPublisher outbox , IFanout fanout , IMediaService media , IGroupDistributor groups )
2026-10-01 11:55:22 +02:00
{
2026-10-01 12:22:08 +02:00
_media = media ;
2026-10-01 12:30:57 +02:00
_groups = groups ;
2026-10-01 11:55:22 +02:00
_dbEntities = dbEntities ;
_localActors = localActors ;
_remoteActors = remoteActors ;
_delivery = delivery ;
_content = content ;
_outbox = outbox ;
_fanout = fanout ;
}
public async Task < StatusOutcome > Publish ( LocalActor author , StatusDraft draft , CancellationToken token )
{
2026-10-01 12:22:08 +02:00
var media = await Media ( author , draft . MediaIds , default , token );
if ( media == default )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Media attachments are invalid" );
if ( string . IsNullOrWhiteSpace ( draft . Text ) && media . Count == 0 )
2026-10-01 11:55:22 +02:00
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Text can't be blank" );
LocalActor group = default ;
if (! string . IsNullOrEmpty ( draft . GroupId ))
{
var groupEntity = await _dbEntities . Groups . MatchID ( draft . GroupId ). ExecuteFirstAsync ( token );
2026-10-01 12:30:57 +02:00
if ( groupEntity == default || groupEntity . DeletionAt . HasValue || ! await MayPost ( groupEntity , author , token ))
2026-10-01 11:55:22 +02:00
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Group not found" );
group = _localActors . FromGroup ( groupEntity );
}
var parent = await Parent ( draft . InReplyTo , token );
if (! string . IsNullOrEmpty ( draft . InReplyTo ) && parent == default && ! IsRemoteUri ( draft . InReplyTo ))
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
if ( parent != default && ! await VisibilityPolicy . CanSee ( parent , author . Id , token ))
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
2026-10-01 12:24:30 +02:00
var located = draft . Latitude . HasValue || draft . Longitude . HasValue ;
if ( located && ( draft . Latitude is not (>= - 90 and <= 90 ) || draft . Longitude is not (>= - 180 and <= 180 ) || group != default
|| draft . Visibility == PostVisibility . Direct ))
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: A located post needs a valid position and no group or recipients" );
2026-10-01 12:22:08 +02:00
var rendered = draft . PlainText ? await _content . PlainText ( draft . Text ?? string . Empty , token ) : await _content . Markdown ( draft . Text ?? string . Empty , token );
2026-10-01 12:30:57 +02:00
string audienceUri = default ;
if ( group == default && ! located && draft . Visibility is PostVisibility . Public or PostVisibility . Unlisted )
foreach ( var mention in rendered . Mentions )
{
if ( mention . IsLocal )
{
var mentioned = await _dbEntities . Groups . MatchID ( mention . AccountId ). ExecuteFirstAsync ( token );
if ( mentioned is { DeletionAt : null , Kind : GroupKind . Community } && await MayPost ( mentioned , author , token ))
{
group = _localActors . FromGroup ( mentioned );
break ;
}
}
else if ( await _dbEntities . ForeignAvatars . MatchID ( mention . AccountId ). ExecuteFirstAsync ( token ) is { AvatarType : Models . User . AvatarType . Group } remoteGroup )
{
audienceUri = remoteGroup . ActorURI ;
break ;
}
}
var isLocalOnly = located ;
2026-10-01 12:24:30 +02:00
var visibility = located ? PostVisibility . LocalGeo
2026-10-01 12:30:57 +02:00
: group is { IsCircle : true } ? PostVisibility . Circle
2026-10-01 12:24:30 +02:00
: draft . Visibility is PostVisibility . Circle or PostVisibility . LocalGeo ? PostVisibility . Public : draft . Visibility ;
2026-10-01 11:55:22 +02:00
var post = new PostEntity
{
GroupUserId = author . Id ,
AuthorAccountId = author . Id ,
GroupId = group ?. Id ,
Visibility = visibility ,
Title = Clean ( draft . Title ),
SpoilerText = Clean ( draft . SpoilerText ),
HasContentWarning = draft . Sensitive || Clean ( draft . SpoilerText ) != default ,
Text = draft . Text ,
ContentHtml = rendered . Html ,
ContentFormat = draft . PlainText ? ContentFormat . Plain : ContentFormat . Markdown ,
Language = Clean ( draft . Language ),
Mentions = rendered . Mentions . Select ( ToMention ). ToList (),
Tags = rendered . Tags . ToList (),
2026-10-01 12:22:08 +02:00
Media = media . Select ( ToPostMedia ). ToList (),
2026-10-01 12:30:57 +02:00
AudienceURI = audienceUri ,
2026-10-01 11:55:22 +02:00
AnsweringToPostId = parent ?. ID ,
InReplyToURI = parent ?. ObjectURI ?? ( IsRemoteUri ( draft . InReplyTo ) ? draft . InReplyTo : default ),
InReplyToAccountId = parent ?. AuthorAccountId ?? parent ?. GroupUserId ,
IsLocalOnly = isLocalOnly ,
ActorURI = author . Uri
};
post . ID = ( string ) post . GenerateNewID ();
post . ObjectURI = author . PostUri ( post . ID );
post . Url = author . PostHtmlUrl ( post . ID );
2026-10-01 12:24:30 +02:00
if ( located )
{
post . Geo = new Coordinates2D ( Math . Round ( draft . Longitude . Value , 2 ), Math . Round ( draft . Latitude . Value , 2 ));
post . RangeKm = ( float ) Math . Clamp ( draft . RangeKm ?? 5 , 1 , 50 );
}
2026-10-01 11:55:22 +02:00
JsonObject create = default ;
if ( visibility == PostVisibility . Direct )
{
var conversation = await Conversation ( author , draft , rendered . Mentions , token );
if (! conversation . Ok )
return StatusOutcome . Fail ( conversation . Status , conversation . Error );
var ( dmGroup , recipients ) = ( conversation . Group , conversation . Recipients );
post . ConversationId = dmGroup . ID ;
post . ContextURI = dmGroup . ConversationURI ;
post . Mentions = recipients . Select ( r => new PostMention { ActorURI = r . Uri , Handle = "@" + r . Handle , IsLocal = r . LocalId != default , AccountId = r . LocalId ?? r . ForeignId })
. Concat ( post . Mentions )
. DistinctBy ( m => m . ActorURI )
. ToList ();
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" ]);
await DB . Default . Update < DmGroup >(). MatchID ( dmGroup . ID ). Modify ( g => g . UpdatedAt , DateTime . UtcNow ). ExecuteAsync ( token );
}
else if (! isLocalOnly )
{
var note = ActivityPubRenderer . Note ( post , author , group , post . InReplyToURI );
create = ActivityPubRenderer . Create ( author , note , $"create-{post.ID}" );
post . To = Strings ( note [ "to" ]);
post . Cc = Strings ( note [ "cc" ]);
}
post . ActivityURI = create ?[ "id" ]?. GetValue < string >();
await DB . Default . SaveAsync ( post , token );
2026-10-01 12:22:08 +02:00
await Attach ( media , post . ID , token );
2026-10-01 11:55:22 +02:00
if ( parent != default )
await DB . Default . Update < PostEntity >(). MatchID ( parent . ID ). Modify ( b => b . Inc ( p => p . RepliesCount , 1 )). ExecuteAsync ( token );
await _fanout . Distribute ( post , token );
if ( create != default )
await _outbox . Publish ( author , post , create , token );
2026-10-01 12:30:57 +02:00
if ( create != default && group is { IsCircle : false } && visibility is PostVisibility . Public or PostVisibility . Unlisted )
await _groups . Announce ( group , create , post . ObjectURI , isNewPost : string . IsNullOrEmpty ( post . InReplyToURI ), token );
2026-10-01 11:55:22 +02:00
return new StatusOutcome ( post );
}
public async Task < StatusOutcome > Edit ( LocalActor author , string postId , StatusDraft draft , CancellationToken token )
{
var post = await Own ( author , postId , token );
if ( post == default )
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
2026-10-01 12:22:08 +02:00
var media = draft . MediaIds == default ? default : await Media ( author , draft . MediaIds , post . ID , token );
if ( draft . MediaIds != default && media == default )
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Media attachments are invalid" );
if ( string . IsNullOrWhiteSpace ( draft . Text ) && ( media ?? new List < MediaAttachment >()). Count == 0 && post . Media . Count == 0 )
2026-10-01 11:55:22 +02:00
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: Text can't be blank" );
post . Revisions . Add ( new PostRevision
{
Title = post . Title ,
SpoilerText = post . SpoilerText ,
ContentHtml = post . ContentHtml ,
HasContentWarning = post . HasContentWarning ,
EditedAt = post . EditedAt ?? post . CreationDate
});
if ( post . Revisions . Count > MaxRevisions )
post . Revisions . RemoveRange ( 0 , post . Revisions . Count - MaxRevisions );
var plain = draft . PlainText || post . ContentFormat == ContentFormat . Plain ;
2026-10-01 12:22:08 +02:00
var rendered = plain ? await _content . PlainText ( draft . Text ?? string . Empty , token ) : await _content . Markdown ( draft . Text ?? string . Empty , token );
if ( media != default )
{
post . Media = media . Select ( ToPostMedia ). ToList ();
await Attach ( media , post . ID , token );
}
2026-10-01 11:55:22 +02:00
post . Title = draft . Title == default ? post . Title : Clean ( draft . Title );
post . SpoilerText = Clean ( draft . SpoilerText );
post . HasContentWarning = draft . Sensitive || post . SpoilerText != default ;
post . Text = draft . Text ;
post . ContentHtml = rendered . Html ;
post . Language = Clean ( draft . Language ) ?? post . Language ;
post . Mentions = post . Visibility == PostVisibility . Direct
? post . Mentions . Concat ( rendered . Mentions . Select ( ToMention )). DistinctBy ( m => m . ActorURI ). ToList ()
: rendered . Mentions . Select ( ToMention ). ToList ();
post . Tags = rendered . Tags . ToList ();
post . EditedAt = DateTime . UtcNow ;
post . UpdateDate = post . EditedAt ;
await DB . Default . SaveAsync ( post , token );
if (! post . IsLocalOnly )
{
var group = string . IsNullOrEmpty ( post . GroupId ) ? default : await _localActors . FindById ( LocalActorKind . Group , post . GroupId , token );
var note = post . Visibility == PostVisibility . Direct
? ActivityPubRenderer . DirectNote ( post , author , Array . Empty <( string , string )>(), post . ContextURI )
: ActivityPubRenderer . Note ( post , author , group , post . InReplyToURI );
note [ "to" ] = new JsonArray ( post . To . Select ( t => ( JsonNode ) t ). ToArray ());
note [ "cc" ] = new JsonArray ( post . Cc . Select ( c => ( JsonNode ) c ). ToArray ());
var update = new JsonObject
{
["@context"] = ActivityPubRenderer . Context (),
["id"] = author . ActivityUri ( $"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeSeconds()}" ),
["type"] = "Update" ,
["actor"] = author . Uri ,
["to"] = note [ "to" ]!. DeepClone (),
["cc"] = note [ "cc" ]!. DeepClone (),
["object"] = note
};
await _outbox . Publish ( author , post , update , token );
2026-10-01 12:30:57 +02:00
if ( group is { IsCircle : false })
await _groups . Announce ( group , update , post . ObjectURI , isNewPost : false , token );
2026-10-01 11:55:22 +02:00
}
return new StatusOutcome ( post );
}
public async Task < StatusOutcome > Remove ( LocalActor author , string postId , CancellationToken token )
{
var post = await _dbEntities . Posts . Match ( p => p . ID == postId && p . GroupUserId == author . Id && ! p . IsFederatedCopy ). ExecuteFirstAsync ( token );
if ( post == default || post . DeletedAt . HasValue )
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
if ( post . ReblogOfPostId != default )
return await Reblog ( author , post . ReblogOfPostId , false , post . Visibility , token );
var audience = await _outbox . Audience ( author , post , token );
await DB . Default . Update < PostEntity >(). MatchID ( post . ID )
. Modify ( p => p . DeletedAt , DateTime . UtcNow )
. Modify ( p => p . Text , null )
. Modify ( p => p . ContentHtml , null )
. Modify ( p => p . Title , null )
. Modify ( p => p . SpoilerText , null )
. Modify ( p => p . Media , new List < PostMedia >())
. Modify ( p => p . Revisions , new List < PostRevision >())
. ExecuteAsync ( token );
await DB . Default . DeleteAsync < TimelineEntry >( e => e . PostId == post . ID || e . ReblogOfPostId == post . ID );
if (! string . IsNullOrEmpty ( post . AnsweringToPostId ))
await DB . Default . Update < PostEntity >(). MatchID ( post . AnsweringToPostId ). Modify ( b => b . Inc ( p => p . RepliesCount , - 1 )). ExecuteAsync ( token );
if ( audience . Count > 0 )
{
var delete = ActivityPubRenderer . Delete ( author , post . ObjectURI , $"delete-{post.ID}" ,
new JsonArray ( post . To . Select ( t => ( JsonNode ) t ). ToArray ()), new JsonArray ( post . Cc . Select ( c => ( JsonNode ) c ). ToArray ()));
await _delivery . Enqueue ( author , audience , delete , token );
2026-10-01 12:30:57 +02:00
var group = string . IsNullOrEmpty ( post . GroupId ) ? default : await _localActors . FindById ( LocalActorKind . Group , post . GroupId , token );
if ( group is { IsCircle : false })
await _groups . Announce ( group , delete , post . ObjectURI , isNewPost : false , token );
2026-10-01 11:55:22 +02:00
}
return new StatusOutcome ( post );
}
public async Task < StatusOutcome > Favourite ( LocalActor me , string postId , bool on , CancellationToken token )
{
var post = await Visible ( me , postId , token );
if ( post == default )
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
var activityId = me . ActivityUri ( $"like-{post.ID}" );
if ( on )
{
try
{
await DB . Default . SaveAsync ( new Favourite { AccountId = me . Id , ActorURI = me . Uri , PostId = post . ID , ActivityURI = activityId }, token );
}
catch ( MongoWriteException ex ) when ( ex . WriteError ?. Category == ServerErrorCategory . DuplicateKey )
{
return new StatusOutcome ( post );
}
await DB . Default . Update < PostEntity >(). MatchID ( post . ID ). Modify ( b => b . Inc ( p => p . FavouritesCount , 1 )). ExecuteAsync ( token );
post . FavouritesCount ++;
if (! post . IsFederatedCopy )
await Notifications . Add ( post . GroupUserId , NotificationType . Favourite , me . Id , me . Uri , post . ID , token );
}
else
{
var removed = await DB . Default . DeleteAsync < Favourite >( f => f . AccountId == me . Id && f . PostId == post . ID );
if ( removed . DeletedCount == 0 )
return new StatusOutcome ( post );
await DB . Default . Update < PostEntity >(). MatchID ( post . ID ). Modify ( b => b . Inc ( p => p . FavouritesCount , - 1 )). ExecuteAsync ( token );
post . FavouritesCount --;
}
if ( post . IsFederatedCopy && await AuthorInbox ( post , token ) is { } inbox )
{
var like = new JsonObject
{
["@context"] = ActivityPubRenderer . ActivityStreams ,
["id"] = activityId ,
["type"] = "Like" ,
["actor"] = me . Uri ,
["object"] = post . ObjectURI
};
await _delivery . Enqueue ( me , new [] { inbox }, on ? like : Undo ( me , like , $"undo-like-{post.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}" ), token );
}
return new StatusOutcome ( post );
}
public async Task < StatusOutcome > Reblog ( LocalActor me , string postId , bool on , PostVisibility visibility , CancellationToken token )
{
var original = await Visible ( me , postId , token );
if ( original ?. ReblogOfPostId != default )
original = await Visible ( me , original . ReblogOfPostId , token );
if ( original == default )
return StatusOutcome . Fail ( StatusCodes . Status404NotFound , "Record not found" );
if ( original . Visibility is not ( PostVisibility . Public or PostVisibility . Unlisted ))
return StatusOutcome . Fail ( StatusCodes . Status422UnprocessableEntity , "Validation failed: This post can't be boosted" );
var existing = await _dbEntities . Posts . Match ( p => p . ReblogOfPostId == original . ID && p . AuthorAccountId == me . Id && ! p . DeletedAt . HasValue ). ExecuteFirstAsync ( token );
var announceId = me . ActivityUri ( $"announce-{original.ID}" );
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer . ActivityStreams ,
["id"] = announceId ,
["type"] = "Announce" ,
["actor"] = me . Uri ,
["published"] = ActivityPubRenderer . Timestamp ( DateTime . UtcNow ),
["to"] = new JsonArray ( visibility == PostVisibility . Unlisted ? me . Followers : ActivityPubRenderer . Public ),
["cc"] = new JsonArray ( visibility == PostVisibility . Unlisted ? ActivityPubRenderer . Public : me . Followers , original . ActorURI ),
["object"] = original . ObjectURI
};
if ( on )
{
if ( existing != default )
return new StatusOutcome ( existing );
var reblog = new PostEntity
{
GroupUserId = me . Id ,
AuthorAccountId = me . Id ,
ReblogOfPostId = original . ID ,
Visibility = visibility == PostVisibility . Unlisted ? PostVisibility . Unlisted : PostVisibility . Public ,
ObjectURI = announceId ,
ActivityURI = announceId ,
ActorURI = me . Uri ,
To = Strings ( announce [ "to" ]),
Cc = Strings ( announce [ "cc" ])
};
try
{
await DB . Default . SaveAsync ( reblog , token );
}
catch ( MongoWriteException ex ) when ( ex . WriteError ?. Category == ServerErrorCategory . DuplicateKey )
{
return new StatusOutcome ( await _dbEntities . Posts . Match ( p => p . ObjectURI == announceId ). ExecuteFirstAsync ( token ));
}
await DB . Default . Update < PostEntity >(). MatchID ( original . ID ). Modify ( b => b . Inc ( p => p . ReblogsCount , 1 )). ExecuteAsync ( token );
if (! original . IsFederatedCopy )
await Notifications . Add ( original . GroupUserId , NotificationType . Reblog , me . Id , me . Uri , original . ID , token );
await _fanout . Distribute ( reblog , token );
await _delivery . EnqueueToFollowers ( me , announce , token , await AuthorInbox ( original , token ) is { } inbox ? new [] { inbox } : default );
return new StatusOutcome ( reblog );
}
if ( existing == default )
return new StatusOutcome ( original );
await DB . Default . DeleteAsync < PostEntity >( existing . ID );
await DB . Default . DeleteAsync < TimelineEntry >( e => e . PostId == existing . ID );
await DB . Default . Update < PostEntity >(). MatchID ( original . ID ). Modify ( b => b . Inc ( p => p . ReblogsCount , - 1 )). ExecuteAsync ( token );
await _delivery . EnqueueToFollowers ( me , Undo ( me , announce , $"undo-announce-{original.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}" ), token ,
await AuthorInbox ( original , token ) is { } authorInbox ? new [] { authorInbox } : default );
return new StatusOutcome ( original );
}
sealed record Recipient ( string Uri , string Handle , string LocalId , string ForeignId , string Inbox );
sealed record ConversationResult ( DmGroup Group , IReadOnlyList < Recipient > Recipients , int Status = StatusCodes . Status200OK , string Error = default )
{
public bool Ok => Error == default ;
}
async Task < ConversationResult > Conversation ( LocalActor author , StatusDraft draft , IReadOnlyList < ResolvedMention > mentioned , CancellationToken token )
{
DmGroup dmGroup = default ;
if (! string . IsNullOrEmpty ( draft . ConversationId ))
{
dmGroup = await _dbEntities . DmGroups . MatchID ( draft . ConversationId ). ExecuteFirstAsync ( token );
if ( dmGroup == default || ! dmGroup . Members . Any ( m => ! m . IsForeign && m . AvatarId == author . Id ))
return new ( default , default , StatusCodes . Status404NotFound , "Conversation not found" );
}
else
{
var members = new List < GroupMember > { new () { AvatarId = author . Id } };
foreach ( var handle in draft . Recipients . Distinct ( StringComparer . OrdinalIgnoreCase ))
{
var member = await ResolveRecipient ( handle , token );
if ( member == default )
return new ( default , default , StatusCodes . Status404NotFound , $"Recipient '{handle}' not found" );
if ( members . All ( m => m . AvatarId != member . AvatarId ))
members . Add ( member );
}
foreach ( var mention in mentioned . Where ( m => m . AccountId != author . Id ))
{
var member = mention . IsLocal ? new GroupMember { AvatarId = mention . AccountId } : new GroupMember { AvatarId = mention . ActorUri , IsForeign = true };
if ( members . All ( m => m . AvatarId != member . AvatarId ))
members . Add ( member );
}
if ( members . Count < 2 )
return new ( default , default , StatusCodes . Status422UnprocessableEntity , "Validation failed: A direct message needs at least one recipient" );
var key = DmGroup . KeyOf ( members );
dmGroup = await _dbEntities . DmGroups . Match ( g => g . ParticipantsKey == key && ! g . DeletionAt . HasValue ). ExecuteFirstAsync ( token );
if ( dmGroup == default )
{
dmGroup = new DmGroup { Members = members , ParticipantsKey = key };
dmGroup . ID = ( string ) dmGroup . GenerateNewID ();
dmGroup . ConversationURI = author . ConversationUri ( dmGroup . ID );
await DB . Default . SaveAsync ( dmGroup , token );
}
}
var recipients = new List < Recipient >();
foreach ( var member in dmGroup . Members . Where ( m => !( m . AvatarId == author . Id && ! m . IsForeign )))
{
if ( member . IsForeign )
{
var foreign = await _dbEntities . ForeignAvatars . Match ( a => a . ActorURI == member . AvatarId ). ExecuteFirstAsync ( token );
if ( foreign != default )
recipients . Add ( new Recipient ( foreign . ActorURI , $"{foreign.UserName}@{foreign.Domain}" , default , foreign . ID , foreign . InboxURL ));
}
else if ( await _localActors . FindById ( LocalActorKind . Person , member . AvatarId , token ) is { } local )
recipients . Add ( new Recipient ( local . Uri , local . Handle , local . Id , default , default ));
}
return new ( dmGroup , recipients );
}
async Task < GroupMember > ResolveRecipient ( string recipient , CancellationToken token )
{
var handle = recipient . Trim (). TrimStart ( '@' );
if (! handle . Contains ( '@' ) || handle . EndsWith ( "@" + new Uri ( _localActors . BaseAddress ). Authority , StringComparison . OrdinalIgnoreCase ))
{
var local = await _localActors . FindByUserName ( handle . Split ( '@' )[ 0 ], token );
return local is { Kind : LocalActorKind . Person } ? new GroupMember { AvatarId = local . Id } : default ;
}
var actorUri = await _remoteActors . ResolveHandle ( handle , token );
var foreign = actorUri == default ? default : await _remoteActors . GetActor ( actorUri , refresh : false , token );
return foreign == default ? default : new GroupMember { AvatarId = foreign . ActorURI , IsForeign = true };
}
async Task < PostEntity > Parent ( string reference , CancellationToken token )
{
if ( string . IsNullOrEmpty ( reference ))
return default ;
return IsRemoteUri ( reference )
? await _dbEntities . Posts . Match ( p => p . ObjectURI == reference && ! p . DeletedAt . HasValue ). ExecuteFirstAsync ( token )
: await _dbEntities . Posts . Match ( p => p . ID == reference && ! p . DeletedAt . HasValue ). ExecuteFirstAsync ( token );
}
async Task < PostEntity > Own ( LocalActor author , string postId , CancellationToken token ) =>
await _dbEntities . Posts . Match ( p => p . ID == postId && p . GroupUserId == author . Id && ! p . IsFederatedCopy && ! p . DeletedAt . HasValue && p . ReblogOfPostId == null )
. ExecuteFirstAsync ( token );
async Task < PostEntity > Visible ( LocalActor me , string postId , CancellationToken token )
{
var post = string . IsNullOrEmpty ( postId ) ? default : await _dbEntities . Posts . Match ( p => p . ID == postId && ! p . DeletedAt . HasValue ). ExecuteFirstAsync ( token );
return post != default && await VisibilityPolicy . CanSee ( post , me . Id , token ) ? post : default ;
}
async Task < string > AuthorInbox ( PostEntity post , CancellationToken token )
{
if (! post . IsFederatedCopy || string . IsNullOrEmpty ( post . AuthorAccountId ))
return default ;
return ( await _dbEntities . ForeignAvatars . MatchID ( post . AuthorAccountId ). ExecuteFirstAsync ( token ))?. InboxURL ;
}
2026-10-01 12:30:57 +02:00
async Task < bool > MayPost ( Models . Group . Group group , LocalActor author , CancellationToken token )
{
var member = group . Members . FirstOrDefault ( m => ! m . IsForeign && m . AvatarId == author . Id );
if ( group . Kind == GroupKind . Circle )
return member != default ;
return group . PostingPolicy switch
{
PostingPolicy . Anyone => true ,
PostingPolicy . Moderators => member ?. Role is GroupRole . Owner or GroupRole . Moderator ,
_ => member != default || await _dbEntities . Followings
. Match ( f => f . AvatarId == author . Id && f . TargetAccountId == group . ID && f . State == FollowState . Accepted ). ExecuteAnyAsync ( token )
};
}
2026-10-01 12:22:08 +02:00
async Task < List < MediaAttachment >> Media ( LocalActor author , IReadOnlyList < string > ids , string postId , CancellationToken token )
{
if ( ids == default || ids . Count == 0 )
return new List < MediaAttachment >();
if ( ids . Count > 4 )
return default ;
var wanted = ids . Distinct (). ToList ();
var found = await DB . Default . Find < MediaAttachment >()
. Match ( m => wanted . Contains ( m . ID ) && m . OwnerAvatarId == author . Id && ( m . PostId == null || m . PostId == postId ))
. ExecuteAsync ( token );
return found . Count == wanted . Count ? wanted . Select ( id => found . First ( m => m . ID == id )). ToList () : default ;
}
static async Task Attach ( IEnumerable < MediaAttachment > media , string postId , CancellationToken token )
{
var ids = media . Select ( m => m . ID ). ToList ();
if ( ids . Count > 0 )
await DB . Default . Update < MediaAttachment >(). Match ( m => ids . Contains ( m . ID ))
. Modify ( m => m . PostId , postId ). Modify ( m => m . AttachedAt , DateTime . UtcNow ). ExecuteAsync ( token );
}
PostMedia ToPostMedia ( MediaAttachment attachment ) => new ()
{
AttachmentId = attachment . ID ,
ContentType = attachment . ContentType ,
URL = _media . Url ( attachment . FilePath ),
PreviewURL = _media . Url ( attachment . PreviewPath ?? attachment . FilePath ),
Description = attachment . Description ,
Blurhash = attachment . Blurhash ,
Width = attachment . Width ,
Height = attachment . Height ,
Focus = attachment . Focus
};
2026-10-01 11:55:22 +02:00
static JsonObject Undo ( LocalActor actor , JsonObject inner , string activityId ) => new ()
{
["@context"] = ActivityPubRenderer . ActivityStreams ,
["id"] = actor . ActivityUri ( activityId ),
["type"] = "Undo" ,
["actor"] = actor . Uri ,
["object"] = inner . DeepClone ()
};
static PostMention ToMention ( ResolvedMention mention ) => new ()
{
ActorURI = mention . ActorUri ,
Handle = "@" + mention . Handle ,
IsLocal = mention . IsLocal ,
AccountId = mention . AccountId
};
static bool IsRemoteUri ( string reference ) =>
reference != default && ( reference . StartsWith ( "https://" , StringComparison . OrdinalIgnoreCase ) || reference . StartsWith ( "http://" , StringComparison . OrdinalIgnoreCase ));
static string Clean ( string value ) => string . IsNullOrWhiteSpace ( value ) ? default : value . Trim ();
static List < string > Strings ( JsonNode node ) =>
node is JsonArray array ? array . Select ( n => n ?. GetValue < string >()). Where ( s => s != default ). ToList () : new List < string >();
}
}