- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in Status.emojis and Account.emojis. - Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and image descriptions (a locked GoToSocial account no longer shows as open). - Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed; our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST /api/v1/polls/:id/votes. - An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision. Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
210 lines
8.7 KiB
C#
210 lines
8.7 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 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 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 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 = Value(note, "quote") ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote"),
|
|
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),
|
|
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)
|
|
};
|
|
}
|
|
|
|
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 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];
|
|
}
|
|
}
|
|
}
|