Files
SocialPub/PrivaPub/Domain/Timelines/Streams.cs
T
thepraandClaude Opus 5.5 9f68ca3ed0 R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions
Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.

Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 05:23:45 +02:00

126 lines
4.6 KiB
C#

using System.Collections.Concurrent;
using System.Threading.Channels;
namespace PrivaPub.Domain.Timelines
{
// Mastodon's streaming, inside this one process: what happens (a post arriving in someone's home or a public feed, a
// notification, an edit, a deletion) is told to the connections listening for it, by id only. Each connection maps a
// post or a notification for its own persona when it sends it (StreamingController), so blocks, mutes and visibility
// are decided there, never here. Nothing is kept: a connection that is gone misses what happened meanwhile, and a
// client reads the timeline again when it comes back.
public static class Streams
{
public const string User = "user";
public const string UserNotification = "user:notification";
public const string Public = "public";
public const string PublicLocal = "public:local";
public const string PublicRemote = "public:remote";
public const string Hashtag = "hashtag";
public const string HashtagLocal = "hashtag:local";
public const string List = "list";
public const string Direct = "direct";
public static readonly string[] Names = { User, UserNotification, Public, PublicLocal, PublicRemote, Hashtag, HashtagLocal, List, Direct };
// what happened: Mastodon's event name, the id of what it is about and, for some, what else the event says (an
// announcement reaction's emoji)
public sealed record Event(string Name, string Id, string Data = default);
// one stream a connection listens to: its name and, for a hashtag or a list, which one
public sealed record Key(string Name, string Parameter = default)
{
public string[] Wire => Parameter == default ? new[] { Name } : new[] { Name, Parameter };
}
public sealed class Listener
{
readonly ConcurrentDictionary<Key, byte> _keys = new();
public Listener(string avatarId)
{
AvatarId = avatarId;
}
public string AvatarId { get; }
// what reached it and was not sent yet; the oldest goes first when a slow connection falls behind
public Channel<(Key Key, Event Event)> Queue { get; } =
Channel.CreateBounded<(Key, Event)>(new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true });
public void Listen(Key key) => _keys[key] = 0;
public void Stop(Key key) => _keys.TryRemove(key, out _);
public bool Hears(Key key) => _keys.ContainsKey(key);
public IEnumerable<Key> Keys => _keys.Keys;
public void Tell(Key key, Event e) => Queue.Writer.TryWrite((key, e));
}
static readonly ConcurrentDictionary<Listener, byte> Listeners = new();
public static Listener Open(string avatarId)
{
var listener = new Listener(avatarId);
Listeners[listener] = 0;
return listener;
}
public static void Close(Listener listener)
{
Listeners.TryRemove(listener, out _);
listener.Queue.Writer.TryComplete();
}
// every connection of a persona ends (its suspension, R6): its queue completes, and the connection closes behind it
public static void CloseFor(string avatarId)
{
foreach (var listener in Listeners.Keys.Where(l => l.AvatarId == avatarId).ToList())
Close(listener);
}
public static int Count => Listeners.Count;
// whether the persona has a connection open, so the work of finding its lists can be skipped when it has none
public static bool Listening(string avatarId) => Listeners.Keys.Any(l => l.AvatarId == avatarId);
// to the persona's own streams: its home (user) and, for a notification, user:notification too
public static void ToPersona(string avatarId, Key key, Event e)
{
foreach (var listener in Listeners.Keys)
if (listener.AvatarId == avatarId && listener.Hears(key))
listener.Tell(key, e);
}
// to everyone listening to a shared stream (public, a hashtag)
public static void ToAll(Key key, Event e)
{
foreach (var listener in Listeners.Keys)
if (listener.Hears(key))
listener.Tell(key, e);
}
// to every stream the persona listens to (a deletion: wherever its client may show the post)
public static void ToPersonaEverywhere(string avatarId, Event e)
{
foreach (var listener in Listeners.Keys)
if (listener.AvatarId == avatarId)
foreach (var key in listener.Keys)
listener.Tell(key, e);
}
// to every persona's home stream (user), as Mastodon tells its announcements: each connection maps it for its own
// persona when it sends it
public static void ToUsers(Event e)
{
var user = new Key(User);
foreach (var listener in Listeners.Keys)
if (listener.Hears(user))
listener.Tell(user, e);
}
public static bool Anyone => !Listeners.IsEmpty;
}
}