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:
thepraandClaude Opus 5.5 committed 2026-10-05 02:48:49 +02:00
1 parent b710493701
commit 5b3e456e09
12 files changed
+759 -13

No files matched your search

+115
View File
@@ -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 };
}
}