Mastodon's streaming API: /api/v1/streaming as a WebSocket (streams
subscribed in the URL or by message) and /api/v1/streaming/{stream} as
server-sent events, with health and the URL advertised. The user stream
tells posts reaching the persona's home (not those an exclusive list keeps
apart, which its list stream tells), notifications, edits and deletions;
public, hashtag and list streams tell what belongs in them. An in-process
hub carries ids only; each connection maps a post or a notification for its
own persona as it sends it, so nothing it may not see, or whose author it
blocked or muted, goes out. A deletion reaches only the streams that showed
the post. The token comes as access_token, header or WebSocket protocol.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
360 lines
17 KiB
C#
360 lines
17 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Entities;
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Api.Mastodon.Mappers;
|
|
using PrivaPub.Domain.Privacy;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
using NotificationEntity = PrivaPub.Models.Social.Notification;
|
|
using MarkerEntity = PrivaPub.Models.Social.Marker;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class TimelinesController : MastodonController
|
|
{
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
public TimelinesController(MastodonMapper mapper, DbEntities dbEntities)
|
|
{
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Home(CancellationToken token)
|
|
{
|
|
//an exclusive list's members are read in that list, not at home
|
|
var exclusive = (await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId && l.Exclusive).ExecuteAsync(token)).Select(l => l.ID).ToList();
|
|
var apart = exclusive.Count == 0
|
|
? new List<string>()
|
|
: (await DB.Default.Find<PersonaListMember>().Match(m => exclusive.Contains(m.ListId)).ExecuteAsync(token)).Select(m => m.AccountId).Distinct().ToList();
|
|
var home = _dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId);
|
|
if (apart.Count > 0)
|
|
home.Match(e => !apart.Contains(e.AuthorAccountId));
|
|
var entries = await Page.From(Params, Limit()).Fetch(home, e => e.PostId, token);
|
|
var ids = entries.Select(e => e.PostId).ToList();
|
|
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
|
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
|
|
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
|
|
return Json(statuses);
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Public(CancellationToken token)
|
|
{
|
|
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
|
if (Params.Bool("local") == true)
|
|
query.Match(p => !p.IsFederatedCopy);
|
|
if (Params.Bool("remote") == true)
|
|
query.Match(p => p.IsFederatedCopy);
|
|
if (Params.Bool("only_media") == true)
|
|
query.Match(p => p.Media.Count > 0);
|
|
return await Respond(query, "/api/v1/timelines/public", token);
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
|
|
{
|
|
var tag = hashtag.TrimStart('#').ToLowerInvariant();
|
|
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
|
if (Params.Bool("local") == true)
|
|
query.Match(p => !p.IsFederatedCopy);
|
|
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
|
|
}
|
|
|
|
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
|
|
public async Task<IActionResult> Favourites(CancellationToken token)
|
|
{
|
|
var favourites = await Page.From(Params, Limit()).Fetch(_dbEntities.Favourites.Match(f => f.AccountId == MyId), f => f.ID, token);
|
|
var ids = favourites.Select(f => f.PostId).ToList();
|
|
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
|
var visible = new List<PostEntity>();
|
|
foreach (var id in ids.Where(posts.ContainsKey))
|
|
if (await VisibilityPolicy.CanSee(posts[id], MyId, token))
|
|
visible.Add(posts[id]);
|
|
Link("/api/v1/favourites", favourites.LastOrDefault()?.ID, favourites.FirstOrDefault()?.ID);
|
|
return Json(await _mapper.Statuses(visible, MyId, token));
|
|
}
|
|
|
|
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Conversations(CancellationToken token)
|
|
{
|
|
var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
|
.Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token);
|
|
var conversations = new List<Conversation>();
|
|
foreach (var group in groups)
|
|
{
|
|
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
|
|
if (last == default)
|
|
continue;
|
|
var others = new List<string>();
|
|
foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign)))
|
|
others.Add(member.IsForeign
|
|
? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID
|
|
: member.AvatarId);
|
|
var accounts = await _mapper.Accounts(others, token);
|
|
conversations.Add(new Conversation
|
|
{
|
|
Id = group.ID,
|
|
Accounts = accounts.Values.ToList(),
|
|
LastStatus = await _mapper.Status(last, MyId, token)
|
|
});
|
|
}
|
|
return Json(conversations);
|
|
}
|
|
|
|
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
|
|
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
|
|
{
|
|
var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token);
|
|
if (group == default)
|
|
return NotFoundError();
|
|
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
|
|
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) });
|
|
}
|
|
|
|
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Markers(CancellationToken token)
|
|
{
|
|
var wanted = Params.List("timeline");
|
|
var markers = await DB.Default.Find<MarkerEntity>().Match(m => m.AvatarId == MyId).ExecuteAsync(token);
|
|
return Json(markers.Where(m => wanted.Count == 0 || wanted.Contains(m.Timeline))
|
|
.ToDictionary(m => m.Timeline, m => new Entities.Marker { LastReadId = m.LastReadId, Version = m.Version, UpdatedAt = MastodonJson.Time(m.UpdatedAt) }));
|
|
}
|
|
|
|
[HttpPost("/api/v1/markers"), Scope("write:statuses")]
|
|
public async Task<IActionResult> SaveMarkers(CancellationToken token)
|
|
{
|
|
var result = new Dictionary<string, Entities.Marker>();
|
|
foreach (var timeline in new[] { "home", "notifications" })
|
|
{
|
|
var lastRead = Params.Get($"{timeline}[last_read_id]");
|
|
if (string.IsNullOrEmpty(lastRead))
|
|
continue;
|
|
var marker = await DB.Default.UpdateAndGet<MarkerEntity>()
|
|
.Match(m => m.AvatarId == MyId && m.Timeline == timeline)
|
|
.Modify(m => m.LastReadId, lastRead)
|
|
.Modify(m => m.UpdatedAt, DateTime.UtcNow)
|
|
.Modify(b => b.Inc(m => m.Version, 1))
|
|
.Option(o => o.IsUpsert = true)
|
|
.ExecuteAsync(token);
|
|
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) };
|
|
}
|
|
return Json(result);
|
|
}
|
|
|
|
async Task<IActionResult> Respond(Find<PostEntity, PostEntity> query, string path, CancellationToken token)
|
|
{
|
|
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
|
Link(path, posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
|
|
return Json(await _mapper.Statuses(posts, MyId, token));
|
|
}
|
|
}
|
|
|
|
public class NotificationsController : MastodonController
|
|
{
|
|
internal static readonly Dictionary<NotificationType, string> Names = new()
|
|
{
|
|
[NotificationType.Mention] = "mention",
|
|
[NotificationType.Follow] = "follow",
|
|
[NotificationType.FollowRequest] = "follow_request",
|
|
[NotificationType.Favourite] = "favourite",
|
|
[NotificationType.Reblog] = "reblog",
|
|
[NotificationType.Update] = "update",
|
|
[NotificationType.Poll] = "poll",
|
|
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
|
[NotificationType.Quote] = "quote"
|
|
};
|
|
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
public NotificationsController(MastodonMapper mapper, DbEntities dbEntities)
|
|
{
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
Find<NotificationEntity, NotificationEntity> Filtered()
|
|
{
|
|
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
|
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
|
var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId);
|
|
if (types.Count > 0)
|
|
query.Match(n => types.Contains(n.Type));
|
|
if (excluded.Count > 0)
|
|
query.Match(n => !excluded.Contains(n.Type));
|
|
if (Params.Get("account_id") is { } accountId)
|
|
query.Match(n => n.FromAccountId == accountId);
|
|
return query;
|
|
}
|
|
|
|
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
|
|
public async Task<IActionResult> List(CancellationToken token)
|
|
{
|
|
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(Filtered(), n => n.ID, token);
|
|
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
|
return Json(await Map(notifications, token));
|
|
}
|
|
|
|
// Grouped notifications (Mastodon 4.3): api_versions.mastodon says we speak that API, so clients call it. Likes and
|
|
// boosts of one post group together, as do follows within an hour; everything else stands alone.
|
|
static readonly HashSet<string> DefaultGrouped = new() { "favourite", "reblog", "follow" };
|
|
|
|
HashSet<string> Grouped() => Params.List("grouped_types") is { Count: > 0 } chosen ? chosen.ToHashSet() : DefaultGrouped;
|
|
|
|
static string GroupKey(NotificationEntity n, ISet<string> grouped) =>
|
|
!grouped.Contains(Names[n.Type]) ? $"ungrouped-{n.ID}"
|
|
: n.Type == NotificationType.Follow ? $"grouped-follow-{n.CreatedAt:yyyyMMddHH}"
|
|
: string.IsNullOrEmpty(n.PostId) ? $"ungrouped-{n.ID}"
|
|
: $"grouped-{Names[n.Type]}-{n.PostId}";
|
|
|
|
// the notifications a group key stands for
|
|
System.Linq.Expressions.Expression<Func<NotificationEntity, bool>> InGroup(string key)
|
|
{
|
|
var me = MyId;
|
|
if (key.StartsWith("ungrouped-", StringComparison.Ordinal))
|
|
{
|
|
var id = key["ungrouped-".Length..];
|
|
return n => n.AvatarId == me && n.ID == id;
|
|
}
|
|
if (key.StartsWith("grouped-follow-", StringComparison.Ordinal)
|
|
&& DateTime.TryParseExact(key["grouped-follow-".Length..], "yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var hour))
|
|
{
|
|
var end = hour.AddHours(1);
|
|
return n => n.AvatarId == me && n.Type == NotificationType.Follow && n.CreatedAt >= hour && n.CreatedAt < end;
|
|
}
|
|
if (key.Split('-') is ["grouped", var name, var postId] && Parse(name) is { } type)
|
|
return n => n.AvatarId == me && n.Type == type && n.PostId == postId;
|
|
return default;
|
|
}
|
|
|
|
async Task<List<NotificationEntity>> Members(string key, CancellationToken token) =>
|
|
InGroup(key) is { } filter
|
|
? await _dbEntities.Notifications.Match(filter).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token)
|
|
: new List<NotificationEntity>();
|
|
|
|
async Task<object> Groups(List<NotificationEntity> page, ISet<string> grouped, CancellationToken token)
|
|
{
|
|
var mapped = (await Map(page, token)).ToDictionary(n => n.Id);
|
|
var shown = page.Where(n => mapped.ContainsKey(n.ID)).ToList();
|
|
var groups = new List<object>();
|
|
foreach (var group in shown.GroupBy(n => GroupKey(n, grouped)))
|
|
{
|
|
var newest = mapped[group.First().ID];
|
|
var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1 : await DB.Default.CountAsync(InGroup(group.Key), token);
|
|
groups.Add(new
|
|
{
|
|
group_key = group.Key,
|
|
notifications_count = Math.Max(total, group.Count()),
|
|
type = newest.Type,
|
|
most_recent_notification_id = newest.Id,
|
|
page_min_id = group.Last().ID,
|
|
page_max_id = group.First().ID,
|
|
latest_page_notification_at = newest.CreatedAt,
|
|
sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(),
|
|
status_id = newest.Status?.Id,
|
|
emoji = newest.Emoji,
|
|
emoji_url = newest.EmojiUrl
|
|
});
|
|
}
|
|
return new
|
|
{
|
|
accounts = mapped.Values.Select(n => n.Account).DistinctBy(a => a.Id).ToList(),
|
|
statuses = mapped.Values.Where(n => n.Status != default).Select(n => n.Status).DistinctBy(s => s.Id).ToList(),
|
|
notification_groups = groups
|
|
};
|
|
}
|
|
|
|
[HttpGet("/api/v2/notifications"), Scope("read:notifications")]
|
|
public async Task<IActionResult> GroupedList(CancellationToken token)
|
|
{
|
|
var notifications = await Page.From(Params, Limit(40, 80)).Fetch(Filtered(), n => n.ID, token);
|
|
Link("/api/v2/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
|
return Json(await Groups(notifications, Grouped(), token));
|
|
}
|
|
|
|
[HttpGet("/api/v2/notifications/unread_count"), Scope("read:notifications")]
|
|
public async Task<IActionResult> GroupedUnreadCount(CancellationToken token)
|
|
{
|
|
var unread = await Filtered().Match(n => !n.IsRead).Sort(n => n.ID, Order.Descending)
|
|
.Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
|
var shown = (await Map(unread, token)).Select(n => n.Id).ToHashSet();
|
|
var grouped = Grouped();
|
|
return Json(new { count = unread.Where(n => shown.Contains(n.ID)).Select(n => GroupKey(n, grouped)).Distinct().Count() });
|
|
}
|
|
|
|
[HttpGet("/api/v2/notifications/{groupKey}"), Scope("read:notifications")]
|
|
public async Task<IActionResult> Group(string groupKey, CancellationToken token)
|
|
{
|
|
var members = await Members(groupKey, token);
|
|
if (members.Count == 0)
|
|
return NotFoundError();
|
|
return Json(await Groups(members, new HashSet<string>(DefaultGrouped.Append(Names[members[0].Type])), token));
|
|
}
|
|
|
|
[HttpGet("/api/v2/notifications/{groupKey}/accounts"), Scope("read:notifications")]
|
|
public async Task<IActionResult> GroupAccounts(string groupKey, CancellationToken token)
|
|
{
|
|
var members = await Members(groupKey, token);
|
|
if (members.Count == 0)
|
|
return NotFoundError();
|
|
return Json((await Map(members, token)).Select(n => n.Account).DistinctBy(a => a.Id).ToList());
|
|
}
|
|
|
|
[HttpPost("/api/v2/notifications/{groupKey}/dismiss"), Scope("write:notifications")]
|
|
public async Task<IActionResult> DismissGroup(string groupKey, CancellationToken token)
|
|
{
|
|
if (InGroup(groupKey) is not { } filter)
|
|
return NotFoundError();
|
|
await DB.Default.DeleteAsync(filter);
|
|
return Json(new { });
|
|
}
|
|
|
|
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
|
|
public async Task<IActionResult> Get(string id, CancellationToken token)
|
|
{
|
|
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
|
var mapped = notification == default ? default : (await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault();
|
|
return mapped == default ? NotFoundError() : Json(mapped);
|
|
}
|
|
|
|
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
|
|
public async Task<IActionResult> Clear(CancellationToken token)
|
|
{
|
|
await DB.Default.DeleteAsync<NotificationEntity>(n => n.AvatarId == MyId);
|
|
return Json(new { });
|
|
}
|
|
|
|
[HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")]
|
|
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
|
|
{
|
|
await DB.Default.DeleteAsync<NotificationEntity>(n => n.ID == id && n.AvatarId == MyId);
|
|
return Json(new { });
|
|
}
|
|
|
|
// counts what the list would show (Map drops notifications from gone accounts and about hidden posts), at most
|
|
// `limit` of them, as Mastodon does
|
|
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
|
|
public async Task<IActionResult> UnreadCount(CancellationToken token)
|
|
{
|
|
var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
|
|
.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
|
return Json(new { count = (await Map(unread, token)).Count });
|
|
}
|
|
|
|
Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token) =>
|
|
_mapper.Notifications(notifications, MyId, token);
|
|
|
|
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
|
|
}
|
|
}
|