[backend/federation] Initial poll support (ISH-62)
This commit is contained in:
parent
8c20be0d95
commit
9de208b49b
19 changed files with 570 additions and 15 deletions
123
Iceshrimp.Backend/Controllers/Mastodon/PollController.cs
Normal file
123
Iceshrimp.Backend/Controllers/Mastodon/PollController.cs
Normal file
|
@ -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<IActionResult> 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<IActionResult> 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<int> incr = [];
|
||||
List<PollVote> 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);
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
|
|||
public class NoteRenderer(
|
||||
IOptions<Config.InstanceSection> 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<List<PollEntity>> GetPolls(IEnumerable<Note> 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<List<EmojiEntity>> GetEmoji(IEnumerable<Note> 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<AccountEntity>? Accounts;
|
||||
public List<MentionEntity>? Mentions;
|
||||
public List<AttachmentEntity>? Attachments;
|
||||
public List<PollEntity>? Polls;
|
||||
public List<string>? LikedNotes;
|
||||
public List<string>? BookmarkedNotes;
|
||||
public List<string>? PinnedNotes;
|
||||
|
|
|
@ -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<PollEntity> 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<List<string>> GetVoted(IEnumerable<Poll> 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<Dictionary<string, int[]>> GetOwnVotes(IEnumerable<Poll> 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<IEnumerable<PollEntity>> RenderManyAsync(IEnumerable<Poll> 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<string, int[]>? OwnVotes;
|
||||
public List<string>? Voted;
|
||||
}
|
||||
}
|
|
@ -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<PollOptionEntity> Options { get; set; }
|
||||
[J("emojis")] public List<EmojiEntity> Emoji => []; //TODO
|
||||
}
|
||||
|
||||
public class PollOptionEntity
|
||||
{
|
||||
[J("title")] public required string Title { get; set; }
|
||||
[J("votes_count")] public required int VotesCount { get; set; }
|
||||
}
|
|
@ -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<MentionEntity> Mentions { get; set; }
|
||||
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
|
||||
[J("emojis")] public required List<EmojiEntity> 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
|
||||
|
||||
|
|
|
@ -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<int> Choices { get; set; }
|
||||
}
|
||||
}
|
|
@ -52,6 +52,8 @@ public static class ServiceExtensions
|
|||
.AddScoped<ErrorHandlerMiddleware>()
|
||||
.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>()
|
||||
.AddScoped<Controllers.Mastodon.Renderers.NoteRenderer>()
|
||||
.AddScoped<PollRenderer>()
|
||||
.AddScoped<PollService>()
|
||||
.AddScoped<NoteRenderer>()
|
||||
.AddScoped<UserRenderer>()
|
||||
.AddScoped<NotificationRenderer>()
|
||||
|
|
|
@ -13,4 +13,9 @@ public static class TaskExtensions
|
|||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<T>> ToListAsync<T>(this Task<IEnumerable<T>> task)
|
||||
{
|
||||
return (await task).ToList();
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
};
|
||||
}
|
|
@ -19,12 +19,9 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
|
|||
/// <returns>ASActor with only the Id field populated</returns>
|
||||
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<ASActor> RenderAsync(User user)
|
||||
|
|
|
@ -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<ASCollectionBase>;
|
|
@ -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))]
|
||||
|
|
|
@ -41,6 +41,7 @@ public class ASObject : ASObjectBase
|
|||
ASActor.Types.Organization => token.ToObject<ASActor>(),
|
||||
ASActor.Types.Application => token.ToObject<ASActor>(),
|
||||
ASNote.Types.Note => token.ToObject<ASNote>(),
|
||||
ASQuestion.Types.Question => token.ToObject<ASQuestion>(),
|
||||
ASCollection.ObjectType => token.ToObject<ASCollection>(),
|
||||
ASCollectionPage.ObjectType => token.ToObject<ASCollectionPage>(),
|
||||
ASOrderedCollection.ObjectType => token.ToObject<ASOrderedCollection>(),
|
||||
|
|
|
@ -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<ASQuestionOption>? _anyOf;
|
||||
private List<ASQuestionOption>? _oneOf;
|
||||
|
||||
[J($"{Constants.ActivityStreamsNs}#oneOf")]
|
||||
public List<ASQuestionOption>? OneOf
|
||||
{
|
||||
get => _oneOf;
|
||||
set => _oneOf = value?[..Math.Min(10, value.Count)];
|
||||
}
|
||||
|
||||
[J($"{Constants.ActivityStreamsNs}#anyOf")]
|
||||
public List<ASQuestionOption>? 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";
|
||||
}
|
||||
}
|
|
@ -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<DatabaseContext>();
|
||||
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<NotificationService>();
|
||||
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;
|
||||
}
|
|
@ -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<string> _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<string>().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<Note> 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<string>().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<string>().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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
|
|
20
Iceshrimp.Backend/Core/Services/PollService.cs
Normal file
20
Iceshrimp.Backend/Core/Services/PollService.cs
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -257,6 +257,15 @@ public class JobQueue<T>(
|
|||
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]
|
||||
|
|
Loading…
Add table
Reference in a new issue