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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user