From 9de208b49b33f434df7ef8c586f82681cd013003 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Mon, 4 Mar 2024 23:28:33 +0100 Subject: [PATCH] [backend/federation] Initial poll support (ISH-62) --- .../Controllers/Mastodon/PollController.cs | 123 ++++++++++++++++++ .../Mastodon/Renderers/NoteRenderer.cs | 18 ++- .../Mastodon/Renderers/PollRenderer.cs | 76 +++++++++++ .../Mastodon/Schemas/Entities/PollEntity.cs | 25 ++++ .../Mastodon/Schemas/Entities/StatusEntity.cs | 13 +- .../Mastodon/Schemas/PollSchemas.cs | 16 +++ .../Core/Extensions/ServiceExtensions.cs | 2 + .../Core/Extensions/TaskExtensions.cs | 5 + .../ActivityPub/ActivityRenderer.cs | 9 ++ .../Federation/ActivityPub/UserRenderer.cs | 9 +- .../ActivityStreams/Types/ASCollectionBase.cs | 21 +++ .../ActivityStreams/Types/ASNote.cs | 4 + .../ActivityStreams/Types/ASObject.cs | 1 + .../ActivityStreams/Types/ASQuestion.cs | 58 +++++++++ .../Core/Queues/BackgroundTaskQueue.cs | 29 +++++ .../Core/Services/NoteService.cs | 110 +++++++++++++++- .../Core/Services/NotificationService.cs | 37 ++++++ .../Core/Services/PollService.cs | 20 +++ .../Core/Services/QueueService.cs | 9 ++ 19 files changed, 570 insertions(+), 15 deletions(-) create mode 100644 Iceshrimp.Backend/Controllers/Mastodon/PollController.cs create mode 100644 Iceshrimp.Backend/Controllers/Mastodon/Renderers/PollRenderer.cs create mode 100644 Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/PollEntity.cs create mode 100644 Iceshrimp.Backend/Controllers/Mastodon/Schemas/PollSchemas.cs create mode 100644 Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASCollectionBase.cs create mode 100644 Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASQuestion.cs create mode 100644 Iceshrimp.Backend/Core/Services/PollService.cs diff --git a/Iceshrimp.Backend/Controllers/Mastodon/PollController.cs b/Iceshrimp.Backend/Controllers/Mastodon/PollController.cs new file mode 100644 index 00000000..f38e0af9 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/PollController.cs @@ -0,0 +1,123 @@ +using System.Net.Mime; +using Iceshrimp.Backend.Controllers.Mastodon.Attributes; +using Iceshrimp.Backend.Controllers.Mastodon.Renderers; +using Iceshrimp.Backend.Controllers.Mastodon.Schemas; +using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; +using Iceshrimp.Backend.Core.Helpers; +using Iceshrimp.Backend.Core.Middleware; +using Iceshrimp.Backend.Core.Services; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; + +namespace Iceshrimp.Backend.Controllers.Mastodon; + +[MastodonApiController] +[Route("/api/v1/polls/{id}")] +[Authenticate] +[EnableCors("mastodon")] +[EnableRateLimiting("sliding")] +[Produces(MediaTypeNames.Application.Json)] +public class PollController(DatabaseContext db, PollRenderer pollRenderer, PollService pollSvc) : ControllerBase +{ + [HttpGet("")] + [Authenticate("read:statuses")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PollEntity))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + public async Task GetPoll(string id) + { + var user = HttpContext.GetUserOrFail(); + var note = await db.Notes.Where(p => p.Id == id).EnsureVisibleFor(user).FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + var poll = await db.Polls.Where(p => p.Note == note).FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + var res = await pollRenderer.RenderAsync(poll, user); + return Ok(res); + } + + [HttpPost("votes")] + [Authenticate("read:statuses")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PollEntity))] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + public async Task VotePoll(string id, [FromHybrid] PollSchemas.PollVoteRequest request) + { + var user = HttpContext.GetUserOrFail(); + var note = await db.Notes.Where(p => p.Id == id) + .IncludeCommonProperties() + .EnsureVisibleFor(user) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + + var poll = await db.Polls.Where(p => p.Note == note).FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); + + if (poll.ExpiresAt < DateTime.UtcNow) + throw GracefulException.BadRequest("This poll is expired"); + + var existingVotes = await db.PollVotes.Where(p => p.User == user && p.Note == note).ToListAsync(); + List incr = []; + List votes = []; + if (!poll.Multiple) + { + if (existingVotes.Count != 0) + throw GracefulException.BadRequest("You already voted on this poll"); + if (request.Choices is not [var choice]) + throw GracefulException.BadRequest("You may only vote for one option"); + if (choice >= poll.Choices.Count) + throw GracefulException.BadRequest($"This poll only has {poll.Choices.Count} options"); + + var vote = new PollVote + { + Id = IdHelpers.GenerateSlowflakeId(), + CreatedAt = DateTime.UtcNow, + User = user, + Note = note, + Choice = choice + }; + + await db.AddAsync(vote); + incr.Add(choice); + votes.Add(vote); + } + else + { + foreach (var choice in request.Choices.Except(existingVotes.Select(p => p.Choice))) + { + if (choice >= poll.Choices.Count) + throw GracefulException.BadRequest($"This poll only has {poll.Choices.Count} options"); + + var vote = new PollVote + { + Id = IdHelpers.GenerateSlowflakeId(), + CreatedAt = DateTime.UtcNow, + User = user, + Note = note, + Choice = choice + }; + + await db.AddAsync(vote); + incr.Add(choice); + votes.Add(vote); + } + } + + await db.SaveChangesAsync(); + foreach (var c in incr) + { + await db.Database + .ExecuteSqlAsync($"""UPDATE "poll" SET "votes"[{c + 1}] = "votes"[{c + 1}] + 1 WHERE "noteId" = {note.Id}"""); + } + + foreach (var vote in votes) + await pollSvc.RegisterPollVote(vote, poll, note); + + await db.ReloadEntityAsync(poll); + var res = await pollRenderer.RenderAsync(poll, user); + return Ok(res); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs index 54695ada..622f4188 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs @@ -12,6 +12,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers; public class NoteRenderer( IOptions config, UserRenderer userRenderer, + PollRenderer pollRenderer, MfmConverter mfmConverter, DatabaseContext db ) @@ -69,6 +70,10 @@ public class NoteRenderer( var account = data?.Accounts?.FirstOrDefault(p => p.Id == note.UserId) ?? await userRenderer.RenderAsync(note.User); + var poll = note.HasPoll + ? (data?.Polls ?? await GetPolls([note], user)).FirstOrDefault(p => p.Id == note.Id) + : null; + var res = new StatusEntity { Id = note.Id, @@ -97,7 +102,8 @@ public class NoteRenderer( Mentions = mentions, IsPinned = pinned, Attachments = attachments, - Emojis = noteEmoji + Emojis = noteEmoji, + Poll = poll }; return res; @@ -170,6 +176,14 @@ public class NoteRenderer( .ToListAsync(); } + private async Task> GetPolls(IEnumerable notes, User? user) + { + var polls = await db.Polls.Where(p => notes.Contains(p.Note)) + .ToListAsync(); + + return await pollRenderer.RenderManyAsync(polls, user).ToListAsync(); + } + private async Task> GetEmoji(IEnumerable notes) { var ids = notes.SelectMany(p => p.Emojis).ToList(); @@ -203,6 +217,7 @@ public class NoteRenderer( Accounts = accounts ?? await GetAccounts(noteList.Select(p => p.User)), Mentions = await GetMentions(noteList), Attachments = await GetAttachments(noteList), + Polls = await GetPolls(noteList, user), LikedNotes = await GetLikedNotes(noteList, user), BookmarkedNotes = await GetBookmarkedNotes(noteList, user), PinnedNotes = await GetPinnedNotes(noteList, user), @@ -218,6 +233,7 @@ public class NoteRenderer( public List? Accounts; public List? Mentions; public List? Attachments; + public List? Polls; public List? LikedNotes; public List? BookmarkedNotes; public List? PinnedNotes; diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/PollRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/PollRenderer.cs new file mode 100644 index 00000000..f10bf7fe --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/PollRenderer.cs @@ -0,0 +1,76 @@ +using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers; + +public class PollRenderer(DatabaseContext db) +{ + public async Task RenderAsync(Poll poll, User? user, PollRendererDto? data = null) + { + var voted = (data?.Voted ?? await GetVoted([poll], user)).Contains(poll.NoteId); + + var ownVotes = (data?.OwnVotes ?? await GetOwnVotes([poll], user)).Where(p => p.Key == poll.NoteId) + .Select(p => p.Value) + .DefaultIfEmpty([]) + .First(); + + var res = new PollEntity + { + Id = poll.NoteId, + Expired = poll.ExpiresAt < DateTime.UtcNow, + Multiple = poll.Multiple, + ExpiresAt = poll.ExpiresAt?.ToStringIso8601Like(), + VotesCount = poll.Votes.Sum(), + VotersCount = !poll.Multiple ? poll.Votes.Sum() : 0, //TODO + Voted = voted, + OwnVotes = ownVotes, + Options = poll.Choices + .Select(p => new PollOptionEntity + { + Title = p, VotesCount = poll.Votes[poll.Choices.IndexOf(p)] + }) + .ToList() + }; + + return res; + } + + private async Task> GetVoted(IEnumerable polls, User? user) + { + if (user == null) return []; + return await db.PollVotes.Where(p => polls.Select(i => i.NoteId).Any(i => i == p.NoteId) && p.User == user) + .Select(p => p.NoteId) + .Distinct() + .ToListAsync(); + } + + private async Task> GetOwnVotes(IEnumerable polls, User? user) + { + if (user == null) return []; + return await db.PollVotes + .Where(p => polls.Select(i => i.NoteId).Any(i => i == p.NoteId) && p.User == user) + .GroupBy(p => p.NoteId) + .ToDictionaryAsync(p => p.Key, p => p.Select(i => i.Choice).ToArray()); + } + + public async Task> RenderManyAsync(IEnumerable polls, User? user) + { + var pollList = polls.ToList(); + + var data = new PollRendererDto + { + OwnVotes = await GetOwnVotes(pollList, user), Voted = await GetVoted(pollList, user) + }; + + return await pollList.Select(p => RenderAsync(p, user, data)).AwaitAllAsync(); + } + + public class PollRendererDto + { + public Dictionary? OwnVotes; + public List? Voted; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/PollEntity.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/PollEntity.cs new file mode 100644 index 00000000..91d73f0e --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/PollEntity.cs @@ -0,0 +1,25 @@ +using Iceshrimp.Backend.Core.Database; +using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; + +public class PollEntity : IEntity +{ + [J("id")] public required string Id { get; set; } + [J("expires_at")] public required string? ExpiresAt { get; set; } + [J("expired")] public required bool Expired { get; set; } + [J("multiple")] public required bool Multiple { get; set; } + [J("votes_count")] public required int VotesCount { get; set; } + [J("voters_count")] public required int? VotersCount { get; set; } + [J("voted")] public required bool Voted { get; set; } + [J("own_votes")] public required int[] OwnVotes { get; set; } + + [J("options")] public required List Options { get; set; } + [J("emojis")] public List Emoji => []; //TODO +} + +public class PollOptionEntity +{ + [J("title")] public required string Title { get; set; } + [J("votes_count")] public required int VotesCount { get; set; } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/StatusEntity.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/StatusEntity.cs index 932015fd..e3ce111c 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/StatusEntity.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/StatusEntity.cs @@ -38,17 +38,18 @@ public class StatusEntity : IEntity [J("pinned")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] public required bool? IsPinned { get; set; } + + [J("poll")] public required PollEntity? Poll { get; set; } [J("mentions")] public required List Mentions { get; set; } [J("media_attachments")] public required List Attachments { get; set; } [J("emojis")] public required List Emojis { get; set; } - [J("tags")] public object[] Tags => []; //FIXME - [J("reactions")] public object[] Reactions => []; //FIXME - [J("filtered")] public object[] Filtered => []; //FIXME - [J("card")] public object? Card => null; //FIXME - [J("poll")] public object? Poll => null; //FIXME - [J("application")] public object? Application => null; //FIXME + [J("tags")] public object[] Tags => []; //FIXME + [J("reactions")] public object[] Reactions => []; //FIXME + [J("filtered")] public object[] Filtered => []; //FIXME + [J("card")] public object? Card => null; //FIXME + [J("application")] public object? Application => null; //FIXME [J("language")] public string? Language => null; //FIXME diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/PollSchemas.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/PollSchemas.cs new file mode 100644 index 00000000..122baa44 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/PollSchemas.cs @@ -0,0 +1,16 @@ +using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; +using JR = System.Text.Json.Serialization.JsonRequiredAttribute; +using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas; + +public class PollSchemas +{ + public class PollVoteRequest + { + [B(Name = "choices")] + [J("choices")] + [JR] + public required List Choices { get; set; } + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index a14ebca5..6415288d 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -52,6 +52,8 @@ public static class ServiceExtensions .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Iceshrimp.Backend/Core/Extensions/TaskExtensions.cs b/Iceshrimp.Backend/Core/Extensions/TaskExtensions.cs index 41b9a129..2326f5f7 100644 --- a/Iceshrimp.Backend/Core/Extensions/TaskExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/TaskExtensions.cs @@ -13,4 +13,9 @@ public static class TaskExtensions // ignored } } + + public static async Task> ToListAsync(this Task> task) + { + return (await task).ToList(); + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs index a369d7c0..d53a9acb 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs @@ -162,4 +162,13 @@ public class ActivityRenderer( return RenderAnnounce(note, actor, to, cc, renoteUri); } + + public ASNote RenderVote(PollVote vote, Poll poll, Note note) => new() + { + Id = GenerateActivityId(), + AttributedTo = [userRenderer.RenderLite(vote.User)], + To = [new ASObjectBase(note.User.Uri ?? note.User.GetPublicUri(config.Value))], + InReplyTo = new ASObjectBase(note.Uri ?? note.GetPublicUri(config.Value)), + Name = poll.Choices[vote.Choice] + }; } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs index 32f6ca9b..d1c143ad 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs @@ -19,12 +19,9 @@ public class UserRenderer(IOptions config, DatabaseConte /// ASActor with only the Id field populated public ASActor RenderLite(User user) { - if (user.Host != null) - { - return new ASActor { Id = user.Uri ?? throw new GracefulException("Remote user must have an URI") }; - } - - return new ASActor { Id = user.GetPublicUri(config.Value) }; + return user.Host != null + ? new ASActor { Id = user.Uri ?? throw new GracefulException("Remote user must have an URI") } + : new ASActor { Id = user.GetPublicUri(config.Value) }; } public async Task RenderAsync(User user) diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASCollectionBase.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASCollectionBase.cs new file mode 100644 index 00000000..225f1729 --- /dev/null +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASCollectionBase.cs @@ -0,0 +1,21 @@ +using Iceshrimp.Backend.Core.Configuration; +using J = Newtonsoft.Json.JsonPropertyAttribute; +using JC = Newtonsoft.Json.JsonConverterAttribute; +using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectConverter; + +namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; + +public class ASCollectionBase : ASObjectBase +{ + [J("@type")] + [JC(typeof(StringListSingleConverter))] + public string Type => ObjectType; + + [J($"{Constants.ActivityStreamsNs}#totalItems")] + [JC(typeof(VC))] + public ulong? TotalItems { get; set; } + + public const string ObjectType = $"{Constants.ActivityStreamsNs}#Collection"; +} + +public sealed class ASCollectionBaseConverter : ASSerializer.ListSingleObjectConverter; \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs index bf983456..714922c9 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs @@ -42,6 +42,10 @@ public class ASNote : ASObject [J($"{Constants.ActivityStreamsNs}#summary")] [JC(typeof(VC))] public string? Summary { get; set; } + + [J($"{Constants.ActivityStreamsNs}#name")] + [JC(typeof(VC))] + public string? Name { get; set; } [J($"{Constants.ActivityStreamsNs}#published")] [JC(typeof(VC))] diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs index 19db453d..f9ff2cc4 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASObject.cs @@ -41,6 +41,7 @@ public class ASObject : ASObjectBase ASActor.Types.Organization => token.ToObject(), ASActor.Types.Application => token.ToObject(), ASNote.Types.Note => token.ToObject(), + ASQuestion.Types.Question => token.ToObject(), ASCollection.ObjectType => token.ToObject(), ASCollectionPage.ObjectType => token.ToObject(), ASOrderedCollection.ObjectType => token.ToObject(), diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASQuestion.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASQuestion.cs new file mode 100644 index 00000000..a371c310 --- /dev/null +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASQuestion.cs @@ -0,0 +1,58 @@ +using Iceshrimp.Backend.Core.Configuration; +using J = Newtonsoft.Json.JsonPropertyAttribute; +using JC = Newtonsoft.Json.JsonConverterAttribute; +using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectConverter; + +namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; + +public class ASQuestion : ASNote +{ + public ASQuestion() => Type = Types.Question; + + private List? _anyOf; + private List? _oneOf; + + [J($"{Constants.ActivityStreamsNs}#oneOf")] + public List? OneOf + { + get => _oneOf; + set => _oneOf = value?[..Math.Min(10, value.Count)]; + } + + [J($"{Constants.ActivityStreamsNs}#anyOf")] + public List? AnyOf + { + get => _anyOf; + set => _anyOf = value?[..Math.Min(10, value.Count)]; + } + + [J($"{Constants.ActivityStreamsNs}#endTime")] + [JC(typeof(VC))] + public DateTime? EndTime { get; set; } + + [J($"{Constants.ActivityStreamsNs}#closed")] + [JC(typeof(VC))] + public DateTime? Closed { get; set; } + + public class ASQuestionOption : ASObjectBase + { + [J("@type")] + [JC(typeof(StringListSingleConverter))] + public string Type => ASNote.Types.Note; + + [J($"{Constants.ActivityStreamsNs}#name")] + [JC(typeof(VC))] + public string? Name { get; set; } + + [J($"{Constants.ActivityStreamsNs}#replies")] + [JC(typeof(ASCollectionBaseConverter))] + public ASCollectionBase? Replies { get; set; } + } + + public new static class Types + { + private const string Ns = Constants.ActivityStreamsNs; + + public const string Question = $"{Ns}#Question"; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs b/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs index 4679da08..297e3b9b 100644 --- a/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs +++ b/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs @@ -1,5 +1,6 @@ using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -29,6 +30,10 @@ public abstract class BackgroundTaskQueue else await ProcessDriveFileDelete(driveFileDeleteJob, scope, token); } + else if (job is PollExpiryJob pollExpiryJob) + { + await ProcessPollExpiry(pollExpiryJob, scope, token); + } } private static async Task ProcessDriveFileDelete( @@ -125,10 +130,28 @@ public abstract class BackgroundTaskQueue } } } + + private static async Task ProcessPollExpiry( + PollExpiryJob job, + IServiceProvider scope, + CancellationToken token + ) + { + var db = scope.GetRequiredService(); + var poll = await db.Polls.FirstOrDefaultAsync(p => p.NoteId == job.NoteId, cancellationToken: token); + if (poll == null) return; + if (poll.ExpiresAt > DateTime.UtcNow + TimeSpan.FromMinutes(5)) return; + var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == poll.NoteId, cancellationToken: token); + if (note == null) return; + //TODO: try to update poll before completing it + var notificationSvc = scope.GetRequiredService(); + await notificationSvc.GeneratePollEndedNotifications(note); + } } [ProtoContract] [ProtoInclude(100, typeof(DriveFileDeleteJob))] +[ProtoInclude(101, typeof(PollExpiryJob))] public class BackgroundTaskJob : Job; [ProtoContract] @@ -136,4 +159,10 @@ public class DriveFileDeleteJob : BackgroundTaskJob { [ProtoMember(1)] public required string DriveFileId; [ProtoMember(2)] public required bool Expire; +} + +[ProtoContract] +public class PollExpiryJob : BackgroundTaskJob +{ + [ProtoMember(1)] public required string NoteId; } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 154a1a51..e00ecd85 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -9,6 +9,7 @@ using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; using Iceshrimp.Backend.Core.Helpers.LibMfm.Types; using Iceshrimp.Backend.Core.Middleware; +using Iceshrimp.Backend.Core.Queues; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -39,7 +40,8 @@ public class NoteService( ActivityPub.ActivityRenderer activityRenderer, EmojiService emojiSvc, FollowupTaskService followupTaskSvc, - ActivityPub.ObjectResolver objectResolver + ActivityPub.ObjectResolver objectResolver, + QueueService queueSvc ) { private readonly List _resolverHistory = []; @@ -61,6 +63,8 @@ public class NoteService( throw GracefulException.BadRequest("Content warning cannot be longer than 100.000 characters"); if (renote?.IsPureRenote ?? false) throw GracefulException.BadRequest("Cannot renote or quote a pure renote"); + if (reply?.IsPureRenote ?? false) + throw GracefulException.BadRequest("Cannot reply to a pure renote"); var (mentionedUserIds, mentionedLocalUserIds, mentions, remoteMentions, splitDomainMapping) = await ResolveNoteMentionsAsync(text); @@ -424,6 +428,36 @@ public class NoteService( if (dbNote.Renote?.IsPureRenote ?? false) throw GracefulException.UnprocessableEntity("Cannot renote or quote a pure renote"); + if (dbNote.Reply?.IsPureRenote ?? false) + throw GracefulException.UnprocessableEntity("Cannot reply to a pure renote"); + + if (note is ASQuestion question) + { + if (question is { AnyOf: not null, OneOf: not null }) + throw GracefulException.UnprocessableEntity("Polls cannot have both anyOf and oneOf set"); + + var choices = (question.AnyOf ?? question.OneOf)?.Where(p => p.Name != null).ToList() ?? + throw GracefulException.UnprocessableEntity("Polls must have either anyOf or oneOf set"); + + if (choices.Count == 0) + throw GracefulException.UnprocessableEntity("Poll must have at least one option"); + + var poll = new Poll + { + Note = dbNote, + UserId = dbNote.User.Id, + UserHost = dbNote.UserHost, + ExpiresAt = question.EndTime ?? question.Closed, + Multiple = question.AnyOf != null, + Choices = choices.Select(p => p.Name).Cast().ToList(), + NoteVisibility = dbNote.Visibility, + Votes = choices.Select(p => (int?)p.Replies?.TotalItems ?? 0).ToList() + }; + + await db.AddAsync(poll); + dbNote.HasPoll = true; + await EnqueuePollExpiryTask(poll); + } if (dbNote.Reply != null) { @@ -492,7 +526,10 @@ public class NoteService( [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "See above")] public async Task ProcessNoteUpdateAsync(ASNote note, User actor) { - var dbNote = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == note.Id); + var dbNote = await db.Notes.IncludeCommonProperties() + .Include(p => p.Poll) + .FirstOrDefaultAsync(p => p.Uri == note.Id); + if (dbNote == null) return await ProcessNoteAsync(note, actor); if (dbNote.User != actor) @@ -549,6 +586,68 @@ public class NoteService( dbNote.Tags = ResolveHashtags(dbNote.Text, note); } + if (note is ASQuestion question) + { + if (question is { AnyOf: not null, OneOf: not null }) + throw GracefulException.UnprocessableEntity("Polls cannot have both anyOf and oneOf set"); + + var choices = (question.AnyOf ?? question.OneOf)?.Where(p => p.Name != null).ToList() ?? + throw GracefulException.UnprocessableEntity("Polls must have either anyOf or oneOf set"); + + if (choices.Count == 0) + throw GracefulException.UnprocessableEntity("Poll must have at least one option"); + + if (dbNote.Poll != null) + { + if (!dbNote.Poll.Choices.SequenceEqual(choices.Select(p => p.Name))) + { + await db.PollVotes.Where(p => p.Note == dbNote).ExecuteDeleteAsync(); + dbNote.Poll.Choices = choices.Select(p => p.Name).Cast().ToList(); + dbNote.Poll.Votes = choices.Select(p => (int?)p.Replies?.TotalItems ?? 0).ToList(); + dbNote.Poll.ExpiresAt = question.EndTime ?? question.Closed; + dbNote.Poll.Multiple = question.AnyOf != null; + db.Update(dbNote.Poll); + } + else + { + dbNote.Poll.Votes = choices.Select(p => (int?)p.Replies?.TotalItems ?? 0).ToList(); + db.Update(dbNote.Poll); + } + } + else + { + var poll = new Poll + { + Note = dbNote, + UserId = dbNote.User.Id, + UserHost = dbNote.UserHost, + ExpiresAt = question.EndTime ?? question.Closed, + Multiple = question.AnyOf != null, + Choices = choices.Select(p => p.Name).Cast().ToList(), + NoteVisibility = dbNote.Visibility, + Votes = choices.Select(p => (int?)p.Replies?.TotalItems ?? 0).ToList() + }; + + await db.AddAsync(poll); + await EnqueuePollExpiryTask(poll); + } + + dbNote.HasPoll = true; + } + else + { + if (dbNote.HasPoll) + { + dbNote.HasPoll = false; + } + + if (dbNote.Poll != null) + { + db.Remove(dbNote.Poll); + dbNote.Poll = null; + } + } + //TODO: handle updated alt text et al var sensitive = (note.Sensitive ?? false) || dbNote.Cw != null; var files = await ProcessAttachmentsAsync(note.Attachments, actor, sensitive); @@ -913,4 +1012,11 @@ public class NoteService( await db.AddRangeAsync(pins); await db.SaveChangesAsync(); } + + private async Task EnqueuePollExpiryTask(Poll poll) + { + if (!poll.ExpiresAt.HasValue) return; + var job = new PollExpiryJob { NoteId = poll.NoteId }; + await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, poll.ExpiresAt.Value); + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/NotificationService.cs b/Iceshrimp.Backend/Core/Services/NotificationService.cs index 0e89ac6b..bbe01b3c 100644 --- a/Iceshrimp.Backend/Core/Services/NotificationService.cs +++ b/Iceshrimp.Backend/Core/Services/NotificationService.cs @@ -180,6 +180,43 @@ public class NotificationService( eventSvc.RaiseNotification(this, notification); } + public async Task GeneratePollEndedNotifications(Note note) + { + var notifications = await db.PollVotes + .Where(p => p.Note == note) + .Where(p => p.User.Host == null) + .Select(p => p.User) + .Distinct() + .Select(p => new Notification + { + Id = IdHelpers.GenerateSlowflakeId(DateTime.UtcNow), + CreatedAt = DateTime.UtcNow, + Notifiee = p, + Notifier = note.User, + Note = note, + Type = Notification.NotificationType.PollEnded + }) + .ToListAsync(); + + if (note.UserHost == null && notifications.All(p => p.Notifiee != note.User)) + { + notifications.Add(new Notification + { + Id = IdHelpers.GenerateSlowflakeId(DateTime.UtcNow), + CreatedAt = DateTime.UtcNow, + Notifiee = note.User, + Note = note, + Type = Notification.NotificationType.PollEnded + }); + } + + await db.AddRangeAsync(notifications); + await db.SaveChangesAsync(); + + foreach (var notification in notifications) + eventSvc.RaiseNotification(this, notification); + } + [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] public async Task GenerateRenoteNotification(Note note) { diff --git a/Iceshrimp.Backend/Core/Services/PollService.cs b/Iceshrimp.Backend/Core/Services/PollService.cs new file mode 100644 index 00000000..dcdeb81e --- /dev/null +++ b/Iceshrimp.Backend/Core/Services/PollService.cs @@ -0,0 +1,20 @@ +using Iceshrimp.Backend.Core.Database.Tables; + +namespace Iceshrimp.Backend.Core.Services; + +public class PollService( + ActivityPub.ActivityRenderer activityRenderer, + ActivityPub.UserRenderer userRenderer, + ActivityPub.ActivityDeliverService deliverSvc +) +{ + public async Task RegisterPollVote(PollVote pollVote, Poll poll, Note note) + { + if (poll.UserHost == null) return; + + var vote = activityRenderer.RenderVote(pollVote, poll, note); + var actor = userRenderer.RenderLite(pollVote.User); + var activity = ActivityPub.ActivityRenderer.RenderCreate(vote, actor); + await deliverSvc.DeliverToAsync(activity, pollVote.User, note.User); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/QueueService.cs b/Iceshrimp.Backend/Core/Services/QueueService.cs index 511fa18f..ad364e7c 100644 --- a/Iceshrimp.Backend/Core/Services/QueueService.cs +++ b/Iceshrimp.Backend/Core/Services/QueueService.cs @@ -257,6 +257,15 @@ public class JobQueue( await _redisDb.ListRightPushAsync("queued", RedisValue.Unbox(RedisHelpers.Serialize(job))); await _subscriber.PublishAsync(_queuedChannel, ""); } + + public async Task ScheduleAsync(T job, DateTime triggerAt) + { + job.Status = Job.JobStatus.Delayed; + job.DelayedUntil = triggerAt; + var timestamp = (long)job.DelayedUntil.Value.Subtract(DateTime.UnixEpoch).TotalSeconds; + await _redisDb.SortedSetAddAsync("delayed", RedisValue.Unbox(RedisHelpers.Serialize(job)), timestamp); + await _subscriber.PublishAsync(_delayedChannel, ""); + } } [ProtoContract]