A persona's notification policy and its requests

Mastodon 4.3's policy was a stub that accepted everything. It is now kept per persona: notifications from accounts it
does not follow, accounts that do not follow it (or only for three days), accounts newer than 30 days, private mentions
it did not ask for and silenced accounts are accepted, filtered or dropped. Filtered ones stay out of every list and
count unless asked for, gathered in a request per account; letting a request in lets that account in for good,
dismissing it deletes them. Everything is accepted until the persona chooses.

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-06 15:48:33 +02:00
1 parent 9872b78677
commit 5d5f1d6909
10 files changed
+511 -29

No files matched your search

@@ -0,0 +1,137 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Social
{
// A persona's notification policy (Mastodon 4.3), applied as each notification is made: accepted, filtered into a
// request per account, or dropped. Polls ending and edits of posts the persona answered are never filtered.
public static class NotificationPolicies
{
static readonly TimeSpan NewAccount = TimeSpan.FromDays(30);
// someone who followed the persona lately is not yet a follower for the policy: following, then writing, is how
// a newcomer gets past it
static readonly TimeSpan NewFollower = TimeSpan.FromDays(3);
public static async Task<NotificationPolicy> Of(string avatarId, CancellationToken token) =>
await DB.Default.Find<NotificationPolicy>().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token)
?? new NotificationPolicy { AvatarId = avatarId };
public static async Task Save(NotificationPolicy policy, CancellationToken token) =>
await DB.Default.Update<NotificationPolicy>().Match(p => p.AvatarId == policy.AvatarId)
.Modify(p => p.ForNotFollowing, policy.ForNotFollowing)
.Modify(p => p.ForNotFollowers, policy.ForNotFollowers)
.Modify(p => p.ForNewAccounts, policy.ForNewAccounts)
.Modify(p => p.ForPrivateMentions, policy.ForPrivateMentions)
.Modify(p => p.ForLimitedAccounts, policy.ForLimitedAccounts)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
public static async Task<NotificationAction> Judge(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
CancellationToken token)
{
if (type is NotificationType.Poll or NotificationType.Update || string.IsNullOrEmpty(fromActorUri))
return NotificationAction.Accept;
var policy = await DB.Default.Find<NotificationPolicy>().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token);
if (policy is null or { ForNotFollowing: NotificationAction.Accept, ForNotFollowers: NotificationAction.Accept, ForNewAccounts: NotificationAction.Accept,
ForPrivateMentions: NotificationAction.Accept, ForLimitedAccounts: NotificationAction.Accept })
return NotificationAction.Accept;
if (await DB.Default.Find<NotificationPermission>().Match(p => p.AvatarId == avatarId && p.FromActorURI == fromActorUri).ExecuteAnyAsync(token))
return NotificationAction.Accept;
var actions = new List<NotificationAction>();
var following = await DB.Default.Find<Following>()
.Match(f => f.AvatarId == avatarId && f.TargetActorURI == fromActorUri && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (!following)
actions.Add(policy.ForNotFollowing);
if (policy.ForNotFollowers != NotificationAction.Accept)
{
var since = DateTime.UtcNow - NewFollower;
if (!await DB.Default.Find<Follower>()
.Match(f => f.LocalActorId == avatarId && f.ActorURI == fromActorUri && f.IsAccepted && f.CreationDate <= since).ExecuteAnyAsync(token))
actions.Add(policy.ForNotFollowers);
}
if (policy.ForNewAccounts != NotificationAction.Accept || policy.ForLimitedAccounts != NotificationAction.Accept)
{
var (since, silenced) = await Sender(fromAccountId, fromActorUri, token);
if (since > DateTime.UtcNow - NewAccount)
actions.Add(policy.ForNewAccounts);
if (silenced)
actions.Add(policy.ForLimitedAccounts);
}
// a private mention it did not ask for: from someone it does not follow, answering nothing of the persona's
if (policy.ForPrivateMentions != NotificationAction.Accept && type == NotificationType.Mention && !following && !string.IsNullOrEmpty(postId)
&& await DB.Default.Find<PostEntity>().Match(p => p.ID == postId && p.Visibility == PostVisibility.Direct && p.InReplyToAccountId != avatarId)
.ExecuteAnyAsync(token))
actions.Add(policy.ForPrivateMentions);
return actions.Contains(NotificationAction.Drop) ? NotificationAction.Drop
: actions.Contains(NotificationAction.Filter) ? NotificationAction.Filter
: NotificationAction.Accept;
}
// since when the sender has been on the fediverse (its actor's published, else when this server first met it), and
// whether it is silenced here, itself or its server
static async Task<(DateTime Since, bool Silenced)> Sender(string accountId, string actorUri, CancellationToken token)
{
var host = Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) ? uri.Host : default;
var silencedHere = host != default && (await DB.Default.Find<DomainBlock>().Match(b => b.Severity == DomainBlockSeverity.Silence).ExecuteAsync(token))
.Any(b => host.Equals(b.Domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + b.Domain, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(accountId) && await DB.Default.Find<Avatar>().Match(a => a.ID == accountId).ExecuteFirstAsync(token) is { } local)
return (local.CreatedAt, local.SilencedAt.HasValue);
var foreign = await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
return foreign == default ? (DateTime.UtcNow, silencedHere) : (foreign.Published ?? foreign.CreatedAt, silencedHere || foreign.SilencedAt.HasValue);
}
// a notification filtered from an account: its request gathers it
public static async Task Requested(Notification notification, CancellationToken token)
{
var now = DateTime.UtcNow;
var update = DB.Default.Update<NotificationRequest>()
.Match(r => r.AvatarId == notification.AvatarId && r.FromActorURI == notification.FromActorURI)
.Modify(b => b.Inc(r => r.NotificationsCount, 1))
.Modify(r => r.FromAccountId, notification.FromAccountId)
.Modify(r => r.UpdatedAt, now)
.Modify(b => b.SetOnInsert(r => r.CreatedAt, now))
.Option(o => o.IsUpsert = true);
if (!string.IsNullOrEmpty(notification.PostId))
update.Modify(r => r.LastPostId, notification.PostId);
try
{
await update.ExecuteAsync(token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// (two filtered at once from one account: the other made the request)
await update.Option(o => o.IsUpsert = false).ExecuteAsync(token);
}
}
// the persona lets an account's notifications in, now and from then on
public static async Task Accept(NotificationRequest request, CancellationToken token)
{
try
{
await DB.Default.SaveAsync(new NotificationPermission { AvatarId = request.AvatarId, FromActorURI = request.FromActorURI }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
await DB.Default.Update<Notification>().Match(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered)
.Modify(n => n.Filtered, false).ExecuteAsync(token);
await DB.Default.DeleteAsync<NotificationRequest>(request.ID);
}
// or dismisses them
public static async Task Dismiss(NotificationRequest request, CancellationToken token)
{
await DB.Default.DeleteAsync<Notification>(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered);
await DB.Default.DeleteAsync<NotificationRequest>(request.ID);
}
}
}
+11 -1
View File
@@ -14,6 +14,9 @@ namespace PrivaPub.Domain.Social
return;
if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0)
return;
var action = await NotificationPolicies.Judge(avatarId, type, fromAccountId, fromActorUri, postId, token);
if (action == NotificationAction.Drop)
return;
try
{
var notification = new Notification
@@ -25,9 +28,16 @@ namespace PrivaPub.Domain.Social
PostId = postId,
Emoji = emoji,
EmojiURL = emojiUrl,
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}"
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}",
Filtered = action == NotificationAction.Filter
};
await DB.Default.SaveAsync(notification, token);
// filtered: in its account's request, told to no stream
if (notification.Filtered)
{
await NotificationPolicies.Requested(notification, token);
return;
}
var told = new Timelines.Streams.Event("notification", notification.ID);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);