[backend/federation] Resolve user profile mentions (ISH-33)
This commit is contained in:
parent
2f3ca1e477
commit
665154b1eb
7 changed files with 139 additions and 16 deletions
|
@ -22,16 +22,19 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, MfmConverter
|
||||||
acct += $"@{user.Host}";
|
acct += $"@{user.Host}";
|
||||||
|
|
||||||
var profileEmoji = emoji?.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([user]);
|
var profileEmoji = emoji?.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([user]);
|
||||||
var fields = profile?.Fields
|
var mentions = profile?.Mentions ?? [];
|
||||||
.Select(p => new Field
|
var fields = profile != null
|
||||||
{
|
? await profile.Fields
|
||||||
Name = p.Name,
|
.Select(async p => new Field
|
||||||
Value = p.Value,
|
{
|
||||||
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
|
Name = p.Name,
|
||||||
? DateTime.Now.ToStringIso8601Like()
|
Value = await mfmConverter.ToHtmlAsync(p.Value, mentions, user.Host),
|
||||||
: null
|
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
|
||||||
})
|
? DateTime.Now.ToStringIso8601Like()
|
||||||
.ToList();
|
: null
|
||||||
|
})
|
||||||
|
.AwaitAllAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
var res = new AccountEntity
|
var res = new AccountEntity
|
||||||
{
|
{
|
||||||
|
@ -46,7 +49,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, MfmConverter
|
||||||
FollowersCount = user.FollowersCount,
|
FollowersCount = user.FollowersCount,
|
||||||
FollowingCount = user.FollowingCount,
|
FollowingCount = user.FollowingCount,
|
||||||
StatusesCount = user.NotesCount,
|
StatusesCount = user.NotesCount,
|
||||||
Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", [], user.Host),
|
Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host),
|
||||||
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
|
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
|
||||||
AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrl(config.Value), //TODO
|
AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrl(config.Value), //TODO
|
||||||
HeaderUrl = user.BannerUrl ?? _transparent,
|
HeaderUrl = user.BannerUrl ?? _transparent,
|
||||||
|
@ -54,7 +57,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, MfmConverter
|
||||||
MovedToAccount = null, //TODO
|
MovedToAccount = null, //TODO
|
||||||
IsBot = user.IsBot,
|
IsBot = user.IsBot,
|
||||||
IsDiscoverable = user.IsExplorable,
|
IsDiscoverable = user.IsExplorable,
|
||||||
Fields = fields ?? [],
|
Fields = fields?.ToList() ?? [],
|
||||||
Emoji = profileEmoji
|
Emoji = profileEmoji
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -297,6 +297,6 @@ public class Note : IEntity
|
||||||
[J("uri")] public required string Uri { get; set; }
|
[J("uri")] public required string Uri { get; set; }
|
||||||
[J("url")] public string? Url { get; set; }
|
[J("url")] public string? Url { get; set; }
|
||||||
[J("username")] public required string Username { get; set; }
|
[J("username")] public required string Username { get; set; }
|
||||||
[J("host")] public required string Host { get; set; }
|
[J("host")] public required string? Host { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -47,6 +47,7 @@ public static class ServiceExtensions
|
||||||
.AddScoped<DriveService>()
|
.AddScoped<DriveService>()
|
||||||
.AddScoped<NotificationService>()
|
.AddScoped<NotificationService>()
|
||||||
.AddScoped<DatabaseMaintenanceService>()
|
.AddScoped<DatabaseMaintenanceService>()
|
||||||
|
.AddScoped<UserProfileMentionsResolver>()
|
||||||
.AddScoped<AuthorizedFetchMiddleware>()
|
.AddScoped<AuthorizedFetchMiddleware>()
|
||||||
.AddScoped<AuthenticationMiddleware>()
|
.AddScoped<AuthenticationMiddleware>()
|
||||||
.AddScoped<ErrorHandlerMiddleware>()
|
.AddScoped<ErrorHandlerMiddleware>()
|
||||||
|
|
|
@ -6,10 +6,10 @@ namespace Iceshrimp.Backend.Core.Extensions;
|
||||||
|
|
||||||
public static class StringExtensions
|
public static class StringExtensions
|
||||||
{
|
{
|
||||||
public static bool EqualsInvariant(this string s1, string s2) =>
|
public static bool EqualsInvariant(this string? s1, string? s2) =>
|
||||||
string.Equals(s1, s2, StringComparison.InvariantCulture);
|
string.Equals(s1, s2, StringComparison.InvariantCulture);
|
||||||
|
|
||||||
public static bool EqualsIgnoreCase(this string s1, string s2) =>
|
public static bool EqualsIgnoreCase(this string? s1, string s2) =>
|
||||||
string.Equals(s1, s2, StringComparison.InvariantCultureIgnoreCase);
|
string.Equals(s1, s2, StringComparison.InvariantCultureIgnoreCase);
|
||||||
|
|
||||||
public static string Truncate(this string target, int maxLength)
|
public static string Truncate(this string target, int maxLength)
|
||||||
|
|
|
@ -133,6 +133,33 @@ public class UserResolver(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<User?> ResolveAsyncLimited(string query, Func<bool> limitReached)
|
||||||
|
{
|
||||||
|
query = NormalizeQuery(query);
|
||||||
|
|
||||||
|
// First, let's see if we already know the user
|
||||||
|
var user = await userSvc.GetUserFromQueryAsync(query);
|
||||||
|
if (user != null)
|
||||||
|
return await GetUpdatedUser(user);
|
||||||
|
|
||||||
|
// We don't, so we need to run WebFinger
|
||||||
|
var (acct, uri) = await WebFingerAsync(query);
|
||||||
|
|
||||||
|
// Check the database again with the new data
|
||||||
|
if (uri != query) user = await userSvc.GetUserFromQueryAsync(uri);
|
||||||
|
if (user == null && acct != query) await userSvc.GetUserFromQueryAsync(acct);
|
||||||
|
if (user != null)
|
||||||
|
return await GetUpdatedUser(user);
|
||||||
|
|
||||||
|
if (limitReached()) return null;
|
||||||
|
|
||||||
|
using (await KeyedLocker.LockAsync(uri))
|
||||||
|
{
|
||||||
|
// Pass the job on to userSvc, which will create the user
|
||||||
|
return await userSvc.CreateUserAsync(uri, acct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<User> GetUpdatedUser(User user)
|
private async Task<User> GetUpdatedUser(User user)
|
||||||
{
|
{
|
||||||
if (!user.NeedsUpdate) return user;
|
if (!user.NeedsUpdate) return user;
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Iceshrimp.Backend.Core.Configuration;
|
||||||
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
|
using Iceshrimp.Backend.Core.Extensions;
|
||||||
|
using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
|
||||||
|
using Iceshrimp.Backend.Core.Helpers.LibMfm.Types;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Iceshrimp.Backend.Core.Services;
|
||||||
|
|
||||||
|
public class UserProfileMentionsResolver(ActivityPub.UserResolver userResolver, IOptions<Config.InstanceSection> config)
|
||||||
|
{
|
||||||
|
private int _recursionLimit = 10;
|
||||||
|
|
||||||
|
public async Task<List<Note.MentionedUser>> ResolveMentions(UserProfile.Field[]? fields, string? bio)
|
||||||
|
{
|
||||||
|
if (fields is not { Length: > 0 } && bio == null) return [];
|
||||||
|
var input = (fields ?? [])
|
||||||
|
.SelectMany<UserProfile.Field, string>(p => [p.Name, p.Value])
|
||||||
|
.Prepend(bio)
|
||||||
|
.Where(p => p != null)
|
||||||
|
.Cast<string>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var nodes = input.SelectMany(p => MfmParser.Parse(p));
|
||||||
|
var mentionNodes = EnumerateMentions(nodes);
|
||||||
|
var users = await mentionNodes.DistinctBy(p => p.Acct)
|
||||||
|
.Select(async p =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await userResolver.ResolveAsyncLimited(p.Acct,
|
||||||
|
() => --_recursionLimit >= 0);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.AwaitAllNoConcurrencyAsync();
|
||||||
|
|
||||||
|
return users.Where(p => p != null)
|
||||||
|
.Cast<User>()
|
||||||
|
.Select(p => new Note.MentionedUser
|
||||||
|
{
|
||||||
|
Host = p.Host,
|
||||||
|
Uri = p.Uri ?? p.GetPublicUri(config.Value),
|
||||||
|
Url = p.UserProfile?.Url,
|
||||||
|
Username = p.Username
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "ReturnTypeCanBeEnumerable.Local",
|
||||||
|
Justification = "Roslyn inspection says this hurts performance")]
|
||||||
|
private static List<MfmMentionNode> EnumerateMentions(IEnumerable<MfmNode> nodes)
|
||||||
|
{
|
||||||
|
var list = new List<MfmMentionNode>();
|
||||||
|
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node is MfmMentionNode mention)
|
||||||
|
list.Add(mention);
|
||||||
|
else
|
||||||
|
list.AddRange(EnumerateMentions(node.Children));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
|
@ -107,6 +107,8 @@ public class UserService(
|
||||||
.Select(p => new UserProfile.Field { Name = p.Name!, Value = p.Value! })
|
.Select(p => new UserProfile.Field { Name = p.Name!, Value = p.Value! })
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
var bio = actor.MkSummary ?? await mfmConverter.FromHtmlAsync(actor.Summary);
|
||||||
|
|
||||||
user = new User
|
user = new User
|
||||||
{
|
{
|
||||||
Id = IdHelpers.GenerateSlowflakeId(),
|
Id = IdHelpers.GenerateSlowflakeId(),
|
||||||
|
@ -136,7 +138,7 @@ public class UserService(
|
||||||
var profile = new UserProfile
|
var profile = new UserProfile
|
||||||
{
|
{
|
||||||
User = user,
|
User = user,
|
||||||
Description = actor.MkSummary ?? await mfmConverter.FromHtmlAsync(actor.Summary),
|
Description = bio,
|
||||||
//Birthday = TODO,
|
//Birthday = TODO,
|
||||||
//Location = TODO,
|
//Location = TODO,
|
||||||
Fields = fields ?? [],
|
Fields = fields ?? [],
|
||||||
|
@ -157,6 +159,7 @@ public class UserService(
|
||||||
var processPendingDeletes = await ResolveAvatarAndBanner(user, actor);
|
var processPendingDeletes = await ResolveAvatarAndBanner(user, actor);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await processPendingDeletes();
|
await processPendingDeletes();
|
||||||
|
UpdateProfileMentionsInBackground(user);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
catch (UniqueConstraintException)
|
catch (UniqueConstraintException)
|
||||||
|
@ -256,6 +259,7 @@ public class UserService(
|
||||||
db.Update(user);
|
db.Update(user);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await processPendingDeletes();
|
await processPendingDeletes();
|
||||||
|
UpdateProfileMentionsInBackground(user);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -586,4 +590,22 @@ public class UserService(
|
||||||
// Clean up user list memberships
|
// Clean up user list memberships
|
||||||
await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync();
|
await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "Projectables")]
|
||||||
|
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]
|
||||||
|
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")]
|
||||||
|
private void UpdateProfileMentionsInBackground(User user)
|
||||||
|
{
|
||||||
|
_ = followupTaskSvc.ExecuteTask("UpdateProfileMentionsInBackground", async provider =>
|
||||||
|
{
|
||||||
|
var bgDbContext = provider.GetRequiredService<DatabaseContext>();
|
||||||
|
var bgMentionsResolver = provider.GetRequiredService<UserProfileMentionsResolver>();
|
||||||
|
var bgUser = await bgDbContext.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id);
|
||||||
|
if (bgUser?.UserProfile == null) return;
|
||||||
|
bgUser.UserProfile.Mentions =
|
||||||
|
await bgMentionsResolver.ResolveMentions(bgUser.UserProfile.Fields, bgUser.UserProfile.Description);
|
||||||
|
bgDbContext.Update(bgUser.UserProfile);
|
||||||
|
await bgDbContext.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Add table
Reference in a new issue