Filters: words and posts a persona would rather not see
Mastodon's v2 filters replace the empty stubs: a filter's title, contexts, action (warn, hide, blur) and expiry, its keywords (whole words or not, taken as JSON objects, listed or numbered form fields, with id and _destroy on update) and its statuses, each with their own endpoints; the v1 API is the same filters seen keyword by keyword. Every status a persona reads carries the filters it matches in `filtered` (a boost as what it boosts), matched as Mastodon matches: warning, title, text, poll options and media descriptions. Clients apply context and action. Filters never federate, and go with a deleted persona. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
b710493701
commit
5b3e456e09
12 files changed
+759
-13
No files matched your search
@@ -0,0 +1,115 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Social
|
||||
{
|
||||
// A viewer's active filters, matched against posts as Mastodon matches them: a keyword anywhere in the post's words
|
||||
// (its warning, title, text, poll options and media descriptions), case-insensitive, whole words when asked (a word
|
||||
// boundary only where the keyword starts or ends with a word character); a filtered status by its id.
|
||||
public sealed class Filters
|
||||
{
|
||||
static readonly AngleSharp.Html.Parser.HtmlParser Html = new();
|
||||
|
||||
readonly List<(PersonaFilter Filter, FilterEntity View, List<(string Keyword, Regex Pattern)> Keywords, HashSet<string> PostIds)> _active;
|
||||
|
||||
Filters(List<PersonaFilter> filters)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
_active = filters.Where(f => f.IsActive(now))
|
||||
.Select(f => (f, View(f), f.Keywords.Select(k => (k.Keyword, Pattern(k))).ToList(), f.Statuses.Select(s => s.PostId).ToHashSet()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static readonly Filters None = new(new List<PersonaFilter>());
|
||||
|
||||
public static async Task<Filters> Of(string viewerId, CancellationToken token) =>
|
||||
viewerId == default ? None : new Filters(await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == viewerId).ExecuteAsync(token));
|
||||
|
||||
public bool IsEmpty => _active.Count == 0;
|
||||
|
||||
public static Regex Pattern(FilterKeyword keyword)
|
||||
{
|
||||
var escaped = Regex.Escape(keyword.Keyword ?? string.Empty);
|
||||
if (keyword.WholeWord)
|
||||
{
|
||||
var start = Regex.IsMatch(keyword.Keyword ?? string.Empty, @"^\w") ? @"\b" : string.Empty;
|
||||
var end = Regex.IsMatch(keyword.Keyword ?? string.Empty, @"\w$") ? @"\b" : string.Empty;
|
||||
escaped = start + escaped + end;
|
||||
}
|
||||
return new Regex(escaped, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
public List<FilterResultEntity> Of(PostEntity post)
|
||||
{
|
||||
if (_active.Count == 0 || post == default)
|
||||
return new List<FilterResultEntity>();
|
||||
string words = default;
|
||||
var results = new List<FilterResultEntity>();
|
||||
foreach (var (filter, view, keywords, postIds) in _active)
|
||||
{
|
||||
var keywordMatches = new List<string>();
|
||||
if (keywords.Count > 0)
|
||||
{
|
||||
words ??= Words(post);
|
||||
foreach (var (keyword, pattern) in keywords)
|
||||
if (Matches(pattern, words))
|
||||
keywordMatches.Add(keyword);
|
||||
}
|
||||
var statusMatches = postIds.Contains(post.ID) ? new List<string> { filter.Statuses.First(s => s.PostId == post.ID).Id } : new List<string>();
|
||||
if (keywordMatches.Count > 0 || statusMatches.Count > 0)
|
||||
results.Add(new FilterResultEntity
|
||||
{
|
||||
Filter = view,
|
||||
KeywordMatches = keywordMatches.Count > 0 ? keywordMatches : default,
|
||||
StatusMatches = statusMatches.Count > 0 ? statusMatches : default
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
static bool Matches(Regex pattern, string words)
|
||||
{
|
||||
try
|
||||
{
|
||||
return pattern.IsMatch(words);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static string Words(PostEntity post)
|
||||
{
|
||||
var text = !string.IsNullOrEmpty(post.Text) && post.IsFederatedCopy == false
|
||||
? post.Text
|
||||
: string.IsNullOrEmpty(post.ContentHtml) ? post.Text : Html.ParseDocument($"<body>{post.ContentHtml}</body>").Body!.TextContent;
|
||||
return string.Join("\n", new[] { post.SpoilerText, post.Title, text }
|
||||
.Concat(post.Poll?.Options.Select(o => o.Title) ?? Enumerable.Empty<string>())
|
||||
.Concat(post.Media.Select(m => m.Description))
|
||||
.Where(s => !string.IsNullOrEmpty(s)));
|
||||
}
|
||||
|
||||
public static FilterEntity View(PersonaFilter filter) => new()
|
||||
{
|
||||
Id = filter.ID,
|
||||
Title = filter.Title,
|
||||
Context = filter.Context,
|
||||
ExpiresAt = filter.ExpiresAt.HasValue ? Api.Mastodon.Infrastructure.MastodonJson.Time(filter.ExpiresAt.Value) : default,
|
||||
FilterAction = filter.Action,
|
||||
Keywords = filter.Keywords.Select(Keyword).ToList(),
|
||||
Statuses = filter.Statuses.Select(Status).ToList()
|
||||
};
|
||||
|
||||
public static FilterKeywordEntity Keyword(FilterKeyword keyword) =>
|
||||
new() { Id = keyword.Id, Keyword = keyword.Keyword, WholeWord = keyword.WholeWord };
|
||||
|
||||
public static FilterStatusEntity Status(FilterStatus status) => new() { Id = status.Id, StatusId = status.PostId };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user