[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(
|
public class NoteRenderer(
|
||||||
IOptions<Config.InstanceSection> config,
|
IOptions<Config.InstanceSection> config,
|
||||||
UserRenderer userRenderer,
|
UserRenderer userRenderer,
|
||||||
|
PollRenderer pollRenderer,
|
||||||
MfmConverter mfmConverter,
|
MfmConverter mfmConverter,
|
||||||
DatabaseContext db
|
DatabaseContext db
|
||||||
)
|
)
|
||||||
|
@ -69,6 +70,10 @@ public class NoteRenderer(
|
||||||
var account = data?.Accounts?.FirstOrDefault(p => p.Id == note.UserId) ??
|
var account = data?.Accounts?.FirstOrDefault(p => p.Id == note.UserId) ??
|
||||||
await userRenderer.RenderAsync(note.User);
|
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
|
var res = new StatusEntity
|
||||||
{
|
{
|
||||||
Id = note.Id,
|
Id = note.Id,
|
||||||
|
@ -97,7 +102,8 @@ public class NoteRenderer(
|
||||||
Mentions = mentions,
|
Mentions = mentions,
|
||||||
IsPinned = pinned,
|
IsPinned = pinned,
|
||||||
Attachments = attachments,
|
Attachments = attachments,
|
||||||
Emojis = noteEmoji
|
Emojis = noteEmoji,
|
||||||
|
Poll = poll
|
||||||
};
|
};
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
@ -170,6 +176,14 @@ public class NoteRenderer(
|
||||||
.ToListAsync();
|
.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)
|
private async Task<List<EmojiEntity>> GetEmoji(IEnumerable<Note> notes)
|
||||||
{
|
{
|
||||||
var ids = notes.SelectMany(p => p.Emojis).ToList();
|
var ids = notes.SelectMany(p => p.Emojis).ToList();
|
||||||
|
@ -203,6 +217,7 @@ public class NoteRenderer(
|
||||||
Accounts = accounts ?? await GetAccounts(noteList.Select(p => p.User)),
|
Accounts = accounts ?? await GetAccounts(noteList.Select(p => p.User)),
|
||||||
Mentions = await GetMentions(noteList),
|
Mentions = await GetMentions(noteList),
|
||||||
Attachments = await GetAttachments(noteList),
|
Attachments = await GetAttachments(noteList),
|
||||||
|
Polls = await GetPolls(noteList, user),
|
||||||
LikedNotes = await GetLikedNotes(noteList, user),
|
LikedNotes = await GetLikedNotes(noteList, user),
|
||||||
BookmarkedNotes = await GetBookmarkedNotes(noteList, user),
|
BookmarkedNotes = await GetBookmarkedNotes(noteList, user),
|
||||||
PinnedNotes = await GetPinnedNotes(noteList, user),
|
PinnedNotes = await GetPinnedNotes(noteList, user),
|
||||||
|
@ -218,6 +233,7 @@ public class NoteRenderer(
|
||||||
public List<AccountEntity>? Accounts;
|
public List<AccountEntity>? Accounts;
|
||||||
public List<MentionEntity>? Mentions;
|
public List<MentionEntity>? Mentions;
|
||||||
public List<AttachmentEntity>? Attachments;
|
public List<AttachmentEntity>? Attachments;
|
||||||
|
public List<PollEntity>? Polls;
|
||||||
public List<string>? LikedNotes;
|
public List<string>? LikedNotes;
|
||||||
public List<string>? BookmarkedNotes;
|
public List<string>? BookmarkedNotes;
|
||||||
public List<string>? PinnedNotes;
|
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; }
|
||||||
|
}
|
|
@ -39,6 +39,8 @@ public class StatusEntity : IEntity
|
||||||
[JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public required bool? IsPinned { get; set; }
|
public required bool? IsPinned { get; set; }
|
||||||
|
|
||||||
|
[J("poll")] public required PollEntity? Poll { get; set; }
|
||||||
|
|
||||||
[J("mentions")] public required List<MentionEntity> Mentions { get; set; }
|
[J("mentions")] public required List<MentionEntity> Mentions { get; set; }
|
||||||
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
|
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
|
||||||
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
|
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
|
||||||
|
@ -47,7 +49,6 @@ public class StatusEntity : IEntity
|
||||||
[J("reactions")] public object[] Reactions => []; //FIXME
|
[J("reactions")] public object[] Reactions => []; //FIXME
|
||||||
[J("filtered")] public object[] Filtered => []; //FIXME
|
[J("filtered")] public object[] Filtered => []; //FIXME
|
||||||
[J("card")] public object? Card => null; //FIXME
|
[J("card")] public object? Card => null; //FIXME
|
||||||
[J("poll")] public object? Poll => null; //FIXME
|
|
||||||
[J("application")] public object? Application => null; //FIXME
|
[J("application")] public object? Application => null; //FIXME
|
||||||
|
|
||||||
[J("language")] public string? Language => 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<ErrorHandlerMiddleware>()
|
||||||
.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>()
|
.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>()
|
||||||
.AddScoped<Controllers.Mastodon.Renderers.NoteRenderer>()
|
.AddScoped<Controllers.Mastodon.Renderers.NoteRenderer>()
|
||||||
|
.AddScoped<PollRenderer>()
|
||||||
|
.AddScoped<PollService>()
|
||||||
.AddScoped<NoteRenderer>()
|
.AddScoped<NoteRenderer>()
|
||||||
.AddScoped<UserRenderer>()
|
.AddScoped<UserRenderer>()
|
||||||
.AddScoped<NotificationRenderer>()
|
.AddScoped<NotificationRenderer>()
|
||||||
|
|
|
@ -13,4 +13,9 @@ public static class TaskExtensions
|
||||||
// ignored
|
// 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);
|
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>
|
/// <returns>ASActor with only the Id field populated</returns>
|
||||||
public ASActor RenderLite(User user)
|
public ASActor RenderLite(User user)
|
||||||
{
|
{
|
||||||
if (user.Host != null)
|
return user.Host != null
|
||||||
{
|
? new ASActor { Id = user.Uri ?? throw new GracefulException("Remote user must have an URI") }
|
||||||
return new ASActor { Id = user.Uri ?? throw new GracefulException("Remote user must have an URI") };
|
: new ASActor { Id = user.GetPublicUri(config.Value) };
|
||||||
}
|
|
||||||
|
|
||||||
return new ASActor { Id = user.GetPublicUri(config.Value) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ASActor> RenderAsync(User user)
|
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>;
|
|
@ -43,6 +43,10 @@ public class ASNote : ASObject
|
||||||
[JC(typeof(VC))]
|
[JC(typeof(VC))]
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
|
|
||||||
|
[J($"{Constants.ActivityStreamsNs}#name")]
|
||||||
|
[JC(typeof(VC))]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
[J($"{Constants.ActivityStreamsNs}#published")]
|
[J($"{Constants.ActivityStreamsNs}#published")]
|
||||||
[JC(typeof(VC))]
|
[JC(typeof(VC))]
|
||||||
public DateTime? PublishedAt { get; set; }
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
|
|
@ -41,6 +41,7 @@ public class ASObject : ASObjectBase
|
||||||
ASActor.Types.Organization => token.ToObject<ASActor>(),
|
ASActor.Types.Organization => token.ToObject<ASActor>(),
|
||||||
ASActor.Types.Application => token.ToObject<ASActor>(),
|
ASActor.Types.Application => token.ToObject<ASActor>(),
|
||||||
ASNote.Types.Note => token.ToObject<ASNote>(),
|
ASNote.Types.Note => token.ToObject<ASNote>(),
|
||||||
|
ASQuestion.Types.Question => token.ToObject<ASQuestion>(),
|
||||||
ASCollection.ObjectType => token.ToObject<ASCollection>(),
|
ASCollection.ObjectType => token.ToObject<ASCollection>(),
|
||||||
ASCollectionPage.ObjectType => token.ToObject<ASCollectionPage>(),
|
ASCollectionPage.ObjectType => token.ToObject<ASCollectionPage>(),
|
||||||
ASOrderedCollection.ObjectType => token.ToObject<ASOrderedCollection>(),
|
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.Configuration;
|
||||||
using Iceshrimp.Backend.Core.Database;
|
using Iceshrimp.Backend.Core.Database;
|
||||||
|
using Iceshrimp.Backend.Core.Extensions;
|
||||||
using Iceshrimp.Backend.Core.Services;
|
using Iceshrimp.Backend.Core.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
@ -29,6 +30,10 @@ public abstract class BackgroundTaskQueue
|
||||||
else
|
else
|
||||||
await ProcessDriveFileDelete(driveFileDeleteJob, scope, token);
|
await ProcessDriveFileDelete(driveFileDeleteJob, scope, token);
|
||||||
}
|
}
|
||||||
|
else if (job is PollExpiryJob pollExpiryJob)
|
||||||
|
{
|
||||||
|
await ProcessPollExpiry(pollExpiryJob, scope, token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ProcessDriveFileDelete(
|
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]
|
[ProtoContract]
|
||||||
[ProtoInclude(100, typeof(DriveFileDeleteJob))]
|
[ProtoInclude(100, typeof(DriveFileDeleteJob))]
|
||||||
|
[ProtoInclude(101, typeof(PollExpiryJob))]
|
||||||
public class BackgroundTaskJob : Job;
|
public class BackgroundTaskJob : Job;
|
||||||
|
|
||||||
[ProtoContract]
|
[ProtoContract]
|
||||||
|
@ -137,3 +160,9 @@ public class DriveFileDeleteJob : BackgroundTaskJob
|
||||||
[ProtoMember(1)] public required string DriveFileId;
|
[ProtoMember(1)] public required string DriveFileId;
|
||||||
[ProtoMember(2)] public required bool Expire;
|
[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.Parsing;
|
||||||
using Iceshrimp.Backend.Core.Helpers.LibMfm.Types;
|
using Iceshrimp.Backend.Core.Helpers.LibMfm.Types;
|
||||||
using Iceshrimp.Backend.Core.Middleware;
|
using Iceshrimp.Backend.Core.Middleware;
|
||||||
|
using Iceshrimp.Backend.Core.Queues;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
@ -39,7 +40,8 @@ public class NoteService(
|
||||||
ActivityPub.ActivityRenderer activityRenderer,
|
ActivityPub.ActivityRenderer activityRenderer,
|
||||||
EmojiService emojiSvc,
|
EmojiService emojiSvc,
|
||||||
FollowupTaskService followupTaskSvc,
|
FollowupTaskService followupTaskSvc,
|
||||||
ActivityPub.ObjectResolver objectResolver
|
ActivityPub.ObjectResolver objectResolver,
|
||||||
|
QueueService queueSvc
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private readonly List<string> _resolverHistory = [];
|
private readonly List<string> _resolverHistory = [];
|
||||||
|
@ -61,6 +63,8 @@ public class NoteService(
|
||||||
throw GracefulException.BadRequest("Content warning cannot be longer than 100.000 characters");
|
throw GracefulException.BadRequest("Content warning cannot be longer than 100.000 characters");
|
||||||
if (renote?.IsPureRenote ?? false)
|
if (renote?.IsPureRenote ?? false)
|
||||||
throw GracefulException.BadRequest("Cannot renote or quote a pure renote");
|
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) =
|
var (mentionedUserIds, mentionedLocalUserIds, mentions, remoteMentions, splitDomainMapping) =
|
||||||
await ResolveNoteMentionsAsync(text);
|
await ResolveNoteMentionsAsync(text);
|
||||||
|
@ -424,6 +428,36 @@ public class NoteService(
|
||||||
|
|
||||||
if (dbNote.Renote?.IsPureRenote ?? false)
|
if (dbNote.Renote?.IsPureRenote ?? false)
|
||||||
throw GracefulException.UnprocessableEntity("Cannot renote or quote a pure renote");
|
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)
|
if (dbNote.Reply != null)
|
||||||
{
|
{
|
||||||
|
@ -492,7 +526,10 @@ public class NoteService(
|
||||||
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "See above")]
|
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "See above")]
|
||||||
public async Task<Note> ProcessNoteUpdateAsync(ASNote note, User actor)
|
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 == null) return await ProcessNoteAsync(note, actor);
|
||||||
|
|
||||||
if (dbNote.User != actor)
|
if (dbNote.User != actor)
|
||||||
|
@ -549,6 +586,68 @@ public class NoteService(
|
||||||
dbNote.Tags = ResolveHashtags(dbNote.Text, note);
|
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
|
//TODO: handle updated alt text et al
|
||||||
var sensitive = (note.Sensitive ?? false) || dbNote.Cw != null;
|
var sensitive = (note.Sensitive ?? false) || dbNote.Cw != null;
|
||||||
var files = await ProcessAttachmentsAsync(note.Attachments, actor, sensitive);
|
var files = await ProcessAttachmentsAsync(note.Attachments, actor, sensitive);
|
||||||
|
@ -913,4 +1012,11 @@ public class NoteService(
|
||||||
await db.AddRangeAsync(pins);
|
await db.AddRangeAsync(pins);
|
||||||
await db.SaveChangesAsync();
|
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);
|
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")]
|
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
|
||||||
public async Task GenerateRenoteNotification(Note note)
|
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 _redisDb.ListRightPushAsync("queued", RedisValue.Unbox(RedisHelpers.Serialize(job)));
|
||||||
await _subscriber.PublishAsync(_queuedChannel, "");
|
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]
|
[ProtoContract]
|
||||||
|
|
Loading…
Add table
Reference in a new issue