[backend/federation] Handle emoji for incoming notes (ISH-89)
This commit is contained in:
parent
da63c26887
commit
69360a8ad7
9 changed files with 127 additions and 18 deletions
|
@ -19,17 +19,17 @@ public class NoteRenderer(
|
|||
public async Task<StatusEntity> RenderAsync(
|
||||
Note note, User? user, List<AccountEntity>? accounts = null, List<MentionEntity>? mentions = null,
|
||||
List<AttachmentEntity>? attachments = null, Dictionary<string, int>? likeCounts = null,
|
||||
List<string>? likedNotes = null, List<string>? renotes = null, int recurse = 2
|
||||
List<string>? likedNotes = null, List<string>? renotes = null, List<EmojiEntity>? emoji = null, int recurse = 2
|
||||
)
|
||||
{
|
||||
var uri = note.Uri ?? note.GetPublicUri(config.Value);
|
||||
var renote = note is { Renote: not null, IsQuote: false } && recurse > 0
|
||||
? await RenderAsync(note.Renote, user, accounts, mentions, attachments, likeCounts, likedNotes,
|
||||
renotes, 0)
|
||||
renotes, emoji, 0)
|
||||
: null;
|
||||
var quote = note is { Renote: not null, IsQuote: true } && recurse > 0
|
||||
? await RenderAsync(note.Renote, user, accounts, mentions, attachments, likeCounts, likedNotes,
|
||||
renotes, --recurse)
|
||||
renotes, emoji, --recurse)
|
||||
: null;
|
||||
var text = note.Text;
|
||||
if (note is { Renote: not null, IsQuote: true } && text != null)
|
||||
|
@ -44,6 +44,8 @@ public class NoteRenderer(
|
|||
var renoted = renotes?.Contains(note.Id) ??
|
||||
await db.Notes.AnyAsync(p => p.Renote == note && p.User == user && p.IsPureRenote);
|
||||
|
||||
var noteEmoji = emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([note]);
|
||||
|
||||
if (mentions == null)
|
||||
{
|
||||
mentions = await db.Users.IncludeCommonProperties()
|
||||
|
@ -119,7 +121,8 @@ public class NoteRenderer(
|
|||
Text = text,
|
||||
Mentions = mentions,
|
||||
IsPinned = false,
|
||||
Attachments = attachments
|
||||
Attachments = attachments,
|
||||
Emojis = noteEmoji
|
||||
};
|
||||
|
||||
return res;
|
||||
|
@ -184,6 +187,24 @@ public class NoteRenderer(
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EmojiEntity>> GetEmoji(IEnumerable<Note> notes)
|
||||
{
|
||||
var ids = notes.SelectMany(p => p.Emojis).ToList();
|
||||
if (ids.Count == 0) return [];
|
||||
|
||||
return await db.Emojis
|
||||
.Where(p => ids.Contains(p.Id))
|
||||
.Select(p => new EmojiEntity
|
||||
{
|
||||
Id = p.Id,
|
||||
Shortcode = p.Name,
|
||||
Url = p.PublicUrl,
|
||||
StaticUrl = p.PublicUrl, //TODO
|
||||
VisibleInPicker = true
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<StatusEntity>> RenderManyAsync(
|
||||
IEnumerable<Note> notes, User? user, List<AccountEntity>? accounts = null
|
||||
)
|
||||
|
@ -200,8 +221,9 @@ public class NoteRenderer(
|
|||
var likeCounts = await GetLikeCounts(noteList);
|
||||
var likedNotes = await GetLikedNotes(noteList, user);
|
||||
var renotes = await GetRenotes(noteList, user);
|
||||
var emoji = await GetEmoji(noteList);
|
||||
return await noteList.Select(p => RenderAsync(p, user, accounts, mentions, attachments, likeCounts, likedNotes,
|
||||
renotes))
|
||||
renotes, emoji))
|
||||
.AwaitAllAsync();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||
|
||||
public class EmojiEntity
|
||||
{
|
||||
[JI] public required string Id;
|
||||
[J("shortcode")] public required string Shortcode { get; set; }
|
||||
[J("static_url")] public required string StaticUrl { get; set; }
|
||||
[J("url")] public required string Url { get; set; }
|
||||
[J("visible_in_picker")] public required bool VisibleInPicker { get; set; }
|
||||
[J("category")] public string? Category { get; set; }
|
||||
}
|
|
@ -43,8 +43,8 @@ public class StatusEntity : IEntity
|
|||
|
||||
[J("mentions")] public required List<MentionEntity> Mentions { get; set; }
|
||||
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
|
||||
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
|
||||
|
||||
[J("emojis")] public object[] Emojis => []; //FIXME
|
||||
[J("tags")] public object[] Tags => []; //FIXME
|
||||
[J("reactions")] public object[] Reactions => []; //FIXME
|
||||
[J("filtered")] public object[] Filtered => []; //FIXME
|
||||
|
|
|
@ -6,6 +6,7 @@ public static class Constants
|
|||
public const string W3IdSecurityNs = "https://w3id.org/security";
|
||||
public const string PurlDcNs = "http://purl.org/dc/terms";
|
||||
public const string XsdNs = "http://www.w3.org/2001/XMLSchema";
|
||||
public const string MastodonNs = "http://joinmastodon.org/ns";
|
||||
public static readonly string[] SystemUsers = ["instance.actor", "relay.actor"];
|
||||
|
||||
public static readonly string[] BrowserSafeMimeTypes =
|
||||
|
|
|
@ -30,7 +30,7 @@ public class Emoji
|
|||
[Column("type")] [StringLength(64)] public string? Type { get; set; }
|
||||
|
||||
[Column("aliases", TypeName = "character varying(128)[]")]
|
||||
public List<string> Aliases { get; set; } = null!;
|
||||
public List<string> Aliases { get; set; } = [];
|
||||
|
||||
[Column("category")]
|
||||
[StringLength(128)]
|
||||
|
|
|
@ -62,7 +62,6 @@ public class NoteRenderer(IOptions<Config.InstanceSection> config, MfmConverter
|
|||
var tags = mentions
|
||||
.Select(mention => new ASMention
|
||||
{
|
||||
Type = $"{Constants.ActivityStreamsNs}#Mention",
|
||||
Name = $"@{mention.Username}@{mention.Host}",
|
||||
Href = new ASObjectBase(mention.Uri)
|
||||
})
|
||||
|
|
|
@ -16,4 +16,4 @@ public class ASImage
|
|||
public bool? Sensitive { get; set; }
|
||||
}
|
||||
|
||||
public class ASImageConverter : ASSerializer.ListSingleObjectConverter<ASImage>;
|
||||
public class ASImageConverter : ASSerializer.ListSingleObjectConverter<ASImage>;
|
||||
|
|
|
@ -3,6 +3,7 @@ using Newtonsoft.Json;
|
|||
using Newtonsoft.Json.Linq;
|
||||
using J = Newtonsoft.Json.JsonPropertyAttribute;
|
||||
using JC = Newtonsoft.Json.JsonConverterAttribute;
|
||||
using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectConverter;
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||
|
||||
|
@ -20,17 +21,35 @@ public class ASTagLink : ASTag
|
|||
public ASObjectBase? Href { get; set; }
|
||||
|
||||
[J($"{Constants.ActivityStreamsNs}#name")]
|
||||
[JC(typeof(ValueObjectConverter))]
|
||||
[JC(typeof(VC))]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
public class ASMention : ASTagLink;
|
||||
public class ASMention : ASTagLink
|
||||
{
|
||||
public ASMention() => Type = $"{Constants.ActivityStreamsNs}#Mention";
|
||||
}
|
||||
|
||||
public class ASHashtag : ASTagLink;
|
||||
public class ASHashtag : ASTagLink
|
||||
{
|
||||
public ASHashtag() => Type = $"{Constants.ActivityStreamsNs}#Hashtag";
|
||||
}
|
||||
|
||||
public class ASEmoji : ASTag
|
||||
{
|
||||
//TODO
|
||||
public ASEmoji() => Type = $"{Constants.MastodonNs}#Emoji";
|
||||
|
||||
[J($"{Constants.ActivityStreamsNs}#updated")]
|
||||
[JC(typeof(VC))]
|
||||
public DateTime? Updated { get; set; }
|
||||
|
||||
[J($"{Constants.ActivityStreamsNs}#icon")]
|
||||
[JC(typeof(ASImageConverter))]
|
||||
public ASImage? Image { get; set; }
|
||||
|
||||
[J($"{Constants.ActivityStreamsNs}#name")]
|
||||
[JC(typeof(VC))]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ASTagConverter : JsonConverter
|
||||
|
@ -73,12 +92,15 @@ public sealed class ASTagConverter : JsonConverter
|
|||
|
||||
private ASTag? HandleObject(JToken obj)
|
||||
{
|
||||
var link = obj.ToObject<ASTagLink?>();
|
||||
if (link is not { Href: not null }) return obj.ToObject<ASEmoji?>();
|
||||
var tag = obj.ToObject<ASTag?>();
|
||||
|
||||
return link.Type == $"{Constants.ActivityStreamsNs}#Mention"
|
||||
? obj.ToObject<ASMention?>()
|
||||
: obj.ToObject<ASHashtag?>();
|
||||
return tag?.Type switch
|
||||
{
|
||||
$"{Constants.ActivityStreamsNs}#Mention" => obj.ToObject<ASMention?>(),
|
||||
$"{Constants.ActivityStreamsNs}#Hashtag" => obj.ToObject<ASHashtag?>(),
|
||||
$"{Constants.MastodonNs}#Emoji" => obj.ToObject<ASEmoji?>(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using AsyncKeyedLock;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Database.Tables;
|
||||
|
@ -43,6 +44,12 @@ public class NoteService(
|
|||
private readonly List<string> _resolverHistory = [];
|
||||
private int _recursionLimit = 100;
|
||||
|
||||
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
|
||||
{
|
||||
o.PoolSize = 100;
|
||||
o.PoolInitialFill = 5;
|
||||
});
|
||||
|
||||
public async Task<Note> CreateNoteAsync(
|
||||
User user, Note.NoteVisibility visibility, string? text = null, string? cw = null, Note? reply = null,
|
||||
Note? renote = null, IReadOnlyCollection<DriveFile>? attachments = null
|
||||
|
@ -313,6 +320,8 @@ public class NoteService(
|
|||
throw GracefulException.UnprocessableEntity("User.Uri doesn't match Note.AttributedTo");
|
||||
if (actor.Uri == null)
|
||||
throw GracefulException.UnprocessableEntity("User.Uri is null");
|
||||
if (actor.Host == null)
|
||||
throw GracefulException.UnprocessableEntity("User.Host is null");
|
||||
if (new Uri(note.Id).IdnHost != new Uri(actor.Uri).IdnHost)
|
||||
throw GracefulException.UnprocessableEntity("User.Uri host doesn't match Note.Id host");
|
||||
if (!note.Id.StartsWith("https://"))
|
||||
|
@ -398,6 +407,9 @@ public class NoteService(
|
|||
dbNote.AttachedFileTypes = files.Select(p => p.Type).ToList();
|
||||
}
|
||||
|
||||
var emoji = await ProcessEmojiAsync(note.Tags?.OfType<ASEmoji>().ToList(), actor.Host);
|
||||
dbNote.Emojis = emoji.Select(p => p.Id).ToList();
|
||||
|
||||
actor.NotesCount++;
|
||||
if (dbNote.Reply != null) dbNote.Reply.RepliesCount++;
|
||||
await db.Notes.AddAsync(dbNote);
|
||||
|
@ -570,6 +582,45 @@ public class NoteService(
|
|||
return (userIds, localUserIds, mentions, remoteMentions, splitDomainMapping);
|
||||
}
|
||||
|
||||
private async Task<List<Emoji>> ProcessEmojiAsync(List<ASEmoji>? emoji, string host)
|
||||
{
|
||||
emoji?.RemoveAll(p => p.Name == null);
|
||||
if (emoji is not { Count: > 0 }) return [];
|
||||
|
||||
foreach (var emojo in emoji) emojo.Name = emojo.Name?.Trim(':');
|
||||
|
||||
var known = await db.Emojis.Where(p => p.Host == host && emoji.Select(e => e.Name).Contains(p.Name))
|
||||
.ToListAsync();
|
||||
|
||||
//TODO: handle updated emoji
|
||||
foreach (var emojo in emoji.Where(emojo => known.All(p => p.Name != emojo.Name)))
|
||||
{
|
||||
using (await KeyedLocker.LockAsync($"emoji:{host}:{emojo.Name}"))
|
||||
{
|
||||
var dbEmojo = await db.Emojis.FirstOrDefaultAsync(p => p.Host == host && p.Name == emojo.Name);
|
||||
if (dbEmojo == null)
|
||||
{
|
||||
dbEmojo = new Emoji
|
||||
{
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
Host = host,
|
||||
Name = emojo.Name ?? throw new Exception("emojo.Name must not be null at this stage"),
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
OriginalUrl = emojo.Image?.Url?.Link ?? throw new Exception("Emoji.Image has no url"),
|
||||
PublicUrl = emojo.Image.Url.Link,
|
||||
Uri = emojo.Id
|
||||
};
|
||||
await db.AddAsync(dbEmojo);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
known.Add(dbEmojo);
|
||||
}
|
||||
}
|
||||
|
||||
return known;
|
||||
}
|
||||
|
||||
private async Task<List<DriveFile>> ProcessAttachmentsAsync(
|
||||
List<ASAttachment>? attachments, User user, bool sensitive
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue