Lemmy's moderation reached PrivaPub only as removals. Now a remote
community's lock and ban, relayed in its Announce, apply too:
- a lock (Announce{Lock}, or commentsEnabled false on the post) refuses
replies to the thread, ours included, until Undo{Lock}; statuses say so
in privapub.locked;
- a ban of a persona (Announce{Block} with the community as target, or the
moderator's own Block sent straight to us, which is the community's ban
and never the moderator's block of the persona) shows as blocked_by on
the community and refuses the persona's posts and replies there until
the Undo.
The Lemmy scenario's removal was an expected failure only because it gave
up before Lemmy's 30-second batch; it now waits, and checks the lock and
the ban live (Lemmy refuses a lock or an unban without a reason): 29
checks, none expected to fail. G-0003 and G-0006 are closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
228 lines
9.8 KiB
C#
228 lines
9.8 KiB
C#
using PrivaPub.Models.Post;
|
|
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
|
|
|
namespace PrivaPub.Federation.Objects
|
|
{
|
|
public sealed class NoteDocument
|
|
{
|
|
public string Id { get; init; }
|
|
public string Type { get; init; }
|
|
public JsonObject Raw { get; init; }
|
|
public string AttributedTo { get; init; }
|
|
public string ContentHtml { get; init; }
|
|
public string Title { get; init; }
|
|
public string SpoilerText { get; init; }
|
|
public string Excerpt { get; init; }
|
|
public bool Sensitive { get; init; }
|
|
public string Language { get; init; }
|
|
public string Url { get; init; }
|
|
public string InReplyTo { get; init; }
|
|
public string Context { get; init; }
|
|
public string Audience { get; init; }
|
|
public string QuoteUri { get; init; }
|
|
public string QuoteAuthorization { get; init; }
|
|
public bool QuotesByConsent { get; init; }//FEP-044f `quote`, as opposed to the legacy keys that ask nobody
|
|
public bool QuoteDeleted { get; init; }
|
|
public DateTime Published { get; init; }
|
|
public DateTime? Updated { get; init; }
|
|
public IReadOnlyList<string> To { get; init; } = Array.Empty<string>();
|
|
public IReadOnlyList<string> Cc { get; init; } = Array.Empty<string>();
|
|
public IReadOnlyList<PostMention> Mentions { get; init; } = Array.Empty<PostMention>();
|
|
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
|
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
|
|
public PostSource Source { get; init; }
|
|
public PostPoll Poll { get; init; }
|
|
public InteractionRule QuotePolicy { get; init; }
|
|
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
|
|
public string CoverURL { get; init; }
|
|
public PostLink Link { get; init; }
|
|
public VideoDetails Video { get; init; }
|
|
public AudioDetails Audio { get; init; }
|
|
public EventDetails Event { get; init; }
|
|
public PostPlace Place { get; init; }
|
|
public bool? CommentsEnabled { get; init; }//Lemmy, PieFed and Mbin: false once its moderators lock it; null when not said
|
|
}
|
|
|
|
public static class NoteParser
|
|
{
|
|
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" };
|
|
const int MaxAttachments = 16;
|
|
const int MaxTags = 64;
|
|
|
|
public static NoteDocument Parse(JsonNode node)
|
|
{
|
|
if (node is not JsonObject note)
|
|
return default;
|
|
var type = Value(note, "type");
|
|
var id = Id(note);
|
|
if (!NoteTypes.Contains(type) || Origin.Of(id) == default)
|
|
return default;
|
|
|
|
var (contentHtml, language) = Content(note);
|
|
var summary = Value(note, "summary");
|
|
var excerpt = ObjectShapes.Text(summary, 1000)
|
|
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
|
|
var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) && marked;
|
|
var summaryWarns = type is "Note" or "Question" || sensitive;
|
|
return new NoteDocument
|
|
{
|
|
Id = id,
|
|
Type = type,
|
|
Raw = note,
|
|
AttributedTo = Attribution(note["attributedTo"]),
|
|
ContentHtml = ContentSanitizer.Html(contentHtml),
|
|
Title = ObjectShapes.Text(Value(note, "name"), 500),
|
|
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
|
|
Excerpt = summaryWarns ? default : excerpt,
|
|
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
|
|
Language = language,
|
|
Url = UrlOf(note["url"]) ?? id,
|
|
InReplyTo = Id(note["inReplyTo"]),
|
|
Context = Id(note["context"]) ?? Value(note, "conversation"),
|
|
Audience = Id(note["audience"]),
|
|
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
|
|
QuoteAuthorization = Id(note["quoteAuthorization"]),
|
|
QuotesByConsent = note.ContainsKey("quote"),
|
|
QuoteDeleted = note["quote"] is JsonObject quoted && Value(quoted, "type") == "Tombstone",
|
|
Published = Time(Value(note, "published")) ?? DateTime.UtcNow,
|
|
Updated = Time(Value(note, "updated")),
|
|
To = List(note["to"]),
|
|
Cc = List(note["cc"]),
|
|
Mentions = Tags(note, "Mention").Select(t => new PostMention { ActorURI = Value(t, "href"), Handle = Value(t, "name") })
|
|
.Where(m => Origin.Of(m.ActorURI) != default)
|
|
.DistinctBy(m => m.ActorURI)
|
|
.Take(MaxTags)
|
|
.ToList(),
|
|
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant())
|
|
.Where(t => !string.IsNullOrEmpty(t))
|
|
.Distinct()
|
|
.Take(MaxTags)
|
|
.ToList(),
|
|
Attachments = Attachments(note),
|
|
Source = ObjectShapes.Source(note),
|
|
Poll = ObjectShapes.Poll(note),
|
|
QuotePolicy = ObjectShapes.QuotePolicy(note),
|
|
Emojis = ObjectShapes.Emojis(note["tag"]),
|
|
CoverURL = ObjectShapes.Cover(note),
|
|
Link = ObjectShapes.Link(note),
|
|
Video = ObjectShapes.Video(note),
|
|
Audio = ObjectShapes.Audio(note),
|
|
Event = ObjectShapes.Event(note),
|
|
Place = ObjectShapes.NotePlace(note),
|
|
CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue<bool>(out var enabled) ? enabled : null
|
|
};
|
|
}
|
|
|
|
static (string Html, string Language) Content(JsonObject note)
|
|
{
|
|
var content = Value(note, "content");
|
|
string language = default;
|
|
if (note["contentMap"] is JsonObject map && map.Count > 0)
|
|
{
|
|
var first = map.First();
|
|
language = first.Key;
|
|
if (string.IsNullOrEmpty(content) && first.Value is JsonValue value && value.TryGetValue<string>(out var mapped))
|
|
content = mapped;
|
|
}
|
|
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
|
|
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
|
|
if (!string.IsNullOrEmpty(content) && Value(note, "mediaType")?.StartsWith("text/markdown", StringComparison.OrdinalIgnoreCase) == true)
|
|
content = Rendering.ActivityPubRenderer.Html(content);
|
|
return (content, language ?? ObjectShapes.Language(note));
|
|
}
|
|
|
|
static string Attribution(JsonNode node) => node switch
|
|
{
|
|
JsonArray array => array.OfType<JsonObject>().Where(o => Value(o, "type") is "Person" or "Service" or "Application" or "Organization").Select(Id)
|
|
.Concat(array.OfType<JsonValue>().Select(Id))
|
|
.Concat(array.OfType<JsonObject>().Where(o => Value(o, "type") == "Group").Select(Id))
|
|
.FirstOrDefault(id => id != default),
|
|
_ => Id(node)
|
|
};
|
|
|
|
static IEnumerable<JsonObject> Tags(JsonObject note, string type) =>
|
|
(note["tag"] switch
|
|
{
|
|
JsonArray array => array.OfType<JsonObject>(),
|
|
JsonObject single => new[] { single },
|
|
_ => Enumerable.Empty<JsonObject>()
|
|
}).Where(t => Value(t, "type") == type);
|
|
|
|
static List<PostMedia> Attachments(JsonObject note) =>
|
|
ObjectShapes.Objects(note["attachment"])
|
|
.Where(a => Value(a, "type") is "Document" or "Image" or "Video" or "Audio")
|
|
.Select(Attachment)
|
|
.Where(m => Origin.Of(m.RemoteURL) != default)
|
|
.Take(MaxAttachments)
|
|
.ToList();
|
|
|
|
static PostMedia Attachment(JsonObject attachment)
|
|
{
|
|
var (url, linkMediaType) = ObjectShapes.MediaLink(attachment["url"] ?? attachment["href"]);
|
|
return new PostMedia
|
|
{
|
|
RemoteURL = url,
|
|
ContentType = ObjectShapes.InferMediaType(Value(attachment, "mediaType") ?? linkMediaType, url, Value(attachment, "type")),
|
|
RemotePreviewURL = ObjectShapes.Image(attachment["icon"]),
|
|
DurationSeconds = ObjectShapes.Seconds(attachment["duration"]),
|
|
Sensitive = attachment["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) ? marked : default(bool?),
|
|
Description = Plain(Value(attachment, "name") ?? Value(attachment, "summary") ?? FirstOf(attachment["nameMap"]) ?? FirstOf(attachment["summaryMap"]), 1500),
|
|
Blurhash = Value(attachment, "blurhash"),
|
|
Width = Int(attachment["width"]),
|
|
Height = Int(attachment["height"]),
|
|
Focus = attachment["focalPoint"] is JsonArray focus && focus.Count == 2
|
|
? focus.Select(f => f is JsonValue v && v.TryGetValue<double>(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray()
|
|
: default
|
|
};
|
|
}
|
|
|
|
static string QuoteLink(JsonObject note) =>
|
|
Tags(note, "Link")
|
|
.Where(t => Value(t, "mediaType") is { } type && (type.StartsWith("application/activity+json") || type.StartsWith("application/ld+json")))
|
|
.Select(t => Value(t, "href"))
|
|
.FirstOrDefault(href => Origin.Of(href) != default);
|
|
|
|
static string FirstOf(JsonNode map) =>
|
|
map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default;
|
|
|
|
static string UrlOf(JsonNode node) => node switch
|
|
{
|
|
JsonValue value when value.TryGetValue<string>(out var text) => text,
|
|
JsonObject obj => Value(obj, "href") ?? Value(obj, "id"),
|
|
JsonArray array => array.Select(item => item is JsonObject link && Value(link, "mediaType") is { } mediaType && !mediaType.StartsWith("text/html")
|
|
? default
|
|
: UrlOf(item))
|
|
.FirstOrDefault(url => url != default) ?? array.Select(UrlOf).FirstOrDefault(url => url != default),
|
|
_ => default
|
|
};
|
|
|
|
static IReadOnlyList<string> List(JsonNode node) => node switch
|
|
{
|
|
JsonArray array => array.Select(Id).Where(id => id != default).Distinct(StringComparer.Ordinal).ToList(),
|
|
JsonNode single when Id(single) is { } id => new[] { id },
|
|
_ => Array.Empty<string>()
|
|
};
|
|
|
|
static DateTime? Time(string value) =>
|
|
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
|
? parsed.UtcDateTime
|
|
: (DateTime?)null;
|
|
|
|
static int? Int(JsonNode node) =>
|
|
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
|
|
|
|
static string Plain(string value, int max)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return default;
|
|
value = value.Trim();
|
|
return value.Length <= max ? value : value[..max];
|
|
}
|
|
}
|
|
}
|