From 49a8b8155148f3c471c899ea2be92a91d8a291d5 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalski Date: Sat, 25 May 2024 12:15:59 +0200 Subject: [PATCH] Implemented polls (#116) * Initial poll implementation * Added entity extensions. Added poll prefix to method name * Added poll dispatch handlers * Added poll permissions * Added REST error codes * Added poll intents * Added poll gateway events * Implemented local poll CreateFrom methods. --- .../Client/DiscordClientBase.Events.cs | 14 +++ .../Limits/Rest/Discord.Limits.Rest.cs | 5 + .../Core/Message/User/IUserMessage.cs | 5 + .../Entities/Core/Message/User/Poll/IPoll.cs | 40 +++++++ .../Core/Message/User/Poll/IPollAnswer.cs | 17 +++ .../Message/User/Poll/IPollAnswerCount.cs | 22 ++++ .../Core/Message/User/Poll/IPollMedia.cs | 17 +++ .../Core/Message/User/Poll/IPollResults.cs | 19 ++++ .../Extensions/LocalMessageBaseExtensions.cs | 7 ++ .../Local/Message/LocalMessageBase.cs | 6 + .../Extensions/LocalPollAnswerExtensions.cs | 14 +++ .../Poll/Extensions/LocalPollExtensions.cs | 91 +++++++++++++++ .../Extensions/LocalPollMediaExtensions.cs | 21 ++++ .../Entities/Local/Message/Poll/LocalPoll.cs | 105 ++++++++++++++++++ .../Local/Message/Poll/LocalPollAnswer.cs | 61 ++++++++++ .../Local/Message/Poll/LocalPollMedia.cs | 67 +++++++++++ .../Message/User/Poll/TransientPoll.cs | 28 +++++ .../Message/User/Poll/TransientPollAnswer.cs | 12 ++ .../User/Poll/TransientPollAnswerCount.cs | 12 ++ .../Message/User/Poll/TransientPollMedia.cs | 13 +++ .../Message/User/Poll/TransientPollResults.cs | 14 +++ .../Message/User/TransientUserMessage.cs | 6 + src/Disqord.Core/Enums/Permissions.cs | 7 +- src/Disqord.Core/Enums/PollLayoutType.cs | 6 + .../Models/Message/MessageJsonModel.cs | 3 + .../Message/Poll/PollAnswerCountsJsonModel.cs | 15 +++ .../Message/Poll/PollAnswerJsonModel.cs | 13 +++ .../Models/Message/Poll/PollJsonModel.cs | 29 +++++ .../Models/Message/Poll/PollMediaJsonModel.cs | 13 +++ .../Message/Poll/PollResultsJsonModel.cs | 12 ++ src/Disqord.Gateway.Api/GatewayIntents.cs | 20 +++- .../Payloads/MessagePollVoteAddJsonModel.cs | 22 ++++ .../MessagePollVoteRemoveJsonModel.cs | 22 ++++ .../Models/Payloads/MessageUpdateJsonModel.cs | 3 + .../Default/DefaultGatewayClient.Events.cs | 14 +++ .../DefaultGatewayDispatcher.Events.cs | 4 + .../Dispatcher/DefaultGatewayDispatcher.cs | 3 + .../Dispatches/Polls/MESSAGE_POLL_VOTE_ADD.cs | 15 +++ .../Polls/MESSAGE_POLL_VOTE_REMOVE.cs | 15 +++ .../EventArgs/Polls/PollVoteAddedEventArgs.cs | 46 ++++++++ .../Polls/PollVoteRemovedEventArgs.cs | 46 ++++++++ .../Cached/Message/User/CachedUserMessage.cs | 7 ++ .../User/TransientGatewayUserMessage.cs | 6 + src/Disqord.Gateway/GatewayDispatchNames.cs | 10 ++ src/Disqord.Gateway/IGatewayClient.Events.cs | 10 ++ .../IGatewayDispatcher.Events.cs | 4 + .../CreateMessageJsonRestRequestContent.cs | 8 ++ .../Methods/RestApiClientExtensions.Poll.cs | 37 ++++++ .../Requests/Routing/Default/Route.Static.cs | 7 ++ src/Disqord.Rest.Api/RestApiErrorCode.cs | 12 ++ .../Entity/RestEntityExtensions.Channel.cs | 31 +++++- .../Entity/RestEntityExtensions.Message.cs | 26 +++++ .../RestClientExtensions.Channel.cs | 2 + .../Extensions/RestClientExtensions.Poll.cs | 54 +++++++++ .../FetchPollAnswerVotersPagedEnumerator.cs | 38 +++++++ .../DiscordClientService.Callbacks.cs | 8 ++ .../DiscordClientMasterService.Hooks.cs | 20 ++++ 57 files changed, 1178 insertions(+), 6 deletions(-) create mode 100644 src/Disqord.Core/Entities/Core/Message/User/Poll/IPoll.cs create mode 100644 src/Disqord.Core/Entities/Core/Message/User/Poll/IPollAnswer.cs create mode 100644 src/Disqord.Core/Entities/Core/Message/User/Poll/IPollAnswerCount.cs create mode 100644 src/Disqord.Core/Entities/Core/Message/User/Poll/IPollMedia.cs create mode 100644 src/Disqord.Core/Entities/Core/Message/User/Poll/IPollResults.cs create mode 100644 src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollAnswerExtensions.cs create mode 100644 src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollExtensions.cs create mode 100644 src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollMediaExtensions.cs create mode 100644 src/Disqord.Core/Entities/Local/Message/Poll/LocalPoll.cs create mode 100644 src/Disqord.Core/Entities/Local/Message/Poll/LocalPollAnswer.cs create mode 100644 src/Disqord.Core/Entities/Local/Message/Poll/LocalPollMedia.cs create mode 100644 src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPoll.cs create mode 100644 src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollAnswer.cs create mode 100644 src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollAnswerCount.cs create mode 100644 src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollMedia.cs create mode 100644 src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollResults.cs create mode 100644 src/Disqord.Core/Enums/PollLayoutType.cs create mode 100644 src/Disqord.Core/Models/Message/Poll/PollAnswerCountsJsonModel.cs create mode 100644 src/Disqord.Core/Models/Message/Poll/PollAnswerJsonModel.cs create mode 100644 src/Disqord.Core/Models/Message/Poll/PollJsonModel.cs create mode 100644 src/Disqord.Core/Models/Message/Poll/PollMediaJsonModel.cs create mode 100644 src/Disqord.Core/Models/Message/Poll/PollResultsJsonModel.cs create mode 100644 src/Disqord.Gateway.Api/Models/Payloads/MessagePollVoteAddJsonModel.cs create mode 100644 src/Disqord.Gateway.Api/Models/Payloads/MessagePollVoteRemoveJsonModel.cs create mode 100644 src/Disqord.Gateway/Default/Dispatcher/Dispatches/Polls/MESSAGE_POLL_VOTE_ADD.cs create mode 100644 src/Disqord.Gateway/Default/Dispatcher/Dispatches/Polls/MESSAGE_POLL_VOTE_REMOVE.cs create mode 100644 src/Disqord.Gateway/Default/Dispatcher/EventArgs/Polls/PollVoteAddedEventArgs.cs create mode 100644 src/Disqord.Gateway/Default/Dispatcher/EventArgs/Polls/PollVoteRemovedEventArgs.cs create mode 100644 src/Disqord.Rest.Api/Methods/RestApiClientExtensions.Poll.cs create mode 100644 src/Disqord.Rest/Extensions/RestClientExtensions.Poll.cs create mode 100644 src/Disqord.Rest/Requests/Pagination/Implementation/FetchPollAnswerVotersPagedEnumerator.cs diff --git a/src/Disqord.Abstractions/Client/DiscordClientBase.Events.cs b/src/Disqord.Abstractions/Client/DiscordClientBase.Events.cs index cf92cb6ae..8a4ef8eac 100644 --- a/src/Disqord.Abstractions/Client/DiscordClientBase.Events.cs +++ b/src/Disqord.Abstractions/Client/DiscordClientBase.Events.cs @@ -383,6 +383,20 @@ public event AsynchronousEventHandler StageDeleted remove => GatewayClient.StageDeleted -= value; } + /// + public event AsynchronousEventHandler PollVoteAdded + { + add => GatewayClient.PollVoteAdded += value; + remove => GatewayClient.PollVoteAdded -= value; + } + + /// + public event AsynchronousEventHandler PollVoteRemoved + { + add => GatewayClient.PollVoteRemoved += value; + remove => GatewayClient.PollVoteRemoved -= value; + } + /// public event AsynchronousEventHandler TypingStarted { diff --git a/src/Disqord.Core/Discord/Limits/Rest/Discord.Limits.Rest.cs b/src/Disqord.Core/Discord/Limits/Rest/Discord.Limits.Rest.cs index 6b1a9b802..7a2095b89 100644 --- a/src/Disqord.Core/Discord/Limits/Rest/Discord.Limits.Rest.cs +++ b/src/Disqord.Core/Discord/Limits/Rest/Discord.Limits.Rest.cs @@ -63,6 +63,11 @@ public static class Rest /// Represents the page size for fetching event users. /// public const int FetchGuildEventUsersPageSize = 100; + + /// + /// Represents the page size for fetching poll answer voters. + /// + public const int FetchPollAnswerVotersPageSize = 100; } } } diff --git a/src/Disqord.Core/Entities/Core/Message/User/IUserMessage.cs b/src/Disqord.Core/Entities/Core/Message/User/IUserMessage.cs index 227e96cb8..b40546249 100644 --- a/src/Disqord.Core/Entities/Core/Message/User/IUserMessage.cs +++ b/src/Disqord.Core/Entities/Core/Message/User/IUserMessage.cs @@ -113,4 +113,9 @@ public interface IUserMessage : IMessage /// Gets the stickers sent with this message. /// IReadOnlyList Stickers { get; } + + /// + /// Gets the poll of this message. + /// + IPoll? Poll { get; } } diff --git a/src/Disqord.Core/Entities/Core/Message/User/Poll/IPoll.cs b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPoll.cs new file mode 100644 index 000000000..8065a83c5 --- /dev/null +++ b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPoll.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace Disqord; + +/// +/// Represents a poll. +/// +public interface IPoll : IEntity +{ + /// + /// Gets the question of this poll. + /// + IPollMedia Question { get; } + + /// + /// Gets the answers of this poll. + /// + IReadOnlyList Answers { get; } + + /// + /// Gets the expiry of this poll. + /// + DateTimeOffset? Expiry { get; } + + /// + /// Gets whether this poll allows selection of multiple answers. + /// + bool AllowMultiselect { get; } + + /// + /// Gets the layout type of this poll. + /// + PollLayoutType LayoutType { get; } + + /// + /// Gets the results of this poll. + /// + IPollResults? Results { get; } +} diff --git a/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollAnswer.cs b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollAnswer.cs new file mode 100644 index 000000000..b8ad13a0d --- /dev/null +++ b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollAnswer.cs @@ -0,0 +1,17 @@ +namespace Disqord; + +/// +/// Represents a poll answer. +/// +public interface IPollAnswer : IEntity +{ + /// + /// Gets the ID of the poll answer. This is used to match based on the ID. + /// + int Id { get; } + + /// + /// Gets the media of this poll answer. + /// + IPollMedia Media { get; } +} diff --git a/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollAnswerCount.cs b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollAnswerCount.cs new file mode 100644 index 000000000..f7799a88b --- /dev/null +++ b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollAnswerCount.cs @@ -0,0 +1,22 @@ +namespace Disqord; + +/// +/// Represents the vote count of a poll answer. +/// +public interface IPollAnswerCount : IEntity +{ + /// + /// Gets the ID of the poll answer. + /// + int AnswerId { get; } + + /// + /// Gets the amount of users that selected the poll answer. + /// + int Count { get; } + + /// + /// Gets whether the bot has selected the poll answer. + /// + bool HasOwnVote { get; } +} diff --git a/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollMedia.cs b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollMedia.cs new file mode 100644 index 000000000..cc0295d3c --- /dev/null +++ b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollMedia.cs @@ -0,0 +1,17 @@ +namespace Disqord; + +/// +/// Represents poll media. +/// +public interface IPollMedia : IEntity +{ + /// + /// Gets the text of this poll media. + /// + string? Text { get; } + + /// + /// Gets the emoji of this poll media. + /// + IEmoji? Emoji { get; } +} diff --git a/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollResults.cs b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollResults.cs new file mode 100644 index 000000000..ccf0016b0 --- /dev/null +++ b/src/Disqord.Core/Entities/Core/Message/User/Poll/IPollResults.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Disqord; + +/// +/// Represents the results of a poll. +/// +public interface IPollResults : IEntity +{ + /// + /// Gets whether the votes have been precisely counted. + /// + bool IsFinalized { get; } + + /// + /// Gets the vote counts of each answer. + /// + IReadOnlyList AnswerCounts { get; } +} diff --git a/src/Disqord.Core/Entities/Local/Message/Extensions/LocalMessageBaseExtensions.cs b/src/Disqord.Core/Entities/Local/Message/Extensions/LocalMessageBaseExtensions.cs index df2a88d25..1a7540ffe 100644 --- a/src/Disqord.Core/Entities/Local/Message/Extensions/LocalMessageBaseExtensions.cs +++ b/src/Disqord.Core/Entities/Local/Message/Extensions/LocalMessageBaseExtensions.cs @@ -147,4 +147,11 @@ public static TMessage WithStickerIds(this TMessage message, params Sn { return message.WithStickerIds(stickerIds as IEnumerable); } + + public static TMessage WithPoll(this TMessage message, LocalPoll poll) + where TMessage : LocalMessageBase + { + message.Poll = poll; + return message; + } } diff --git a/src/Disqord.Core/Entities/Local/Message/LocalMessageBase.cs b/src/Disqord.Core/Entities/Local/Message/LocalMessageBase.cs index fd30e50ea..0afe71cfe 100644 --- a/src/Disqord.Core/Entities/Local/Message/LocalMessageBase.cs +++ b/src/Disqord.Core/Entities/Local/Message/LocalMessageBase.cs @@ -51,6 +51,11 @@ public abstract class LocalMessageBase : ILocalConstruct /// public Optional> StickerIds { get; set; } + /// + /// Gets or sets the poll of this message. + /// + public Optional Poll { get; set; } + /// /// Instantiates a new . /// @@ -71,6 +76,7 @@ protected LocalMessageBase(LocalMessageBase other) Attachments = other.Attachments.DeepClone(); Components = other.Components.DeepClone(); StickerIds = other.StickerIds.Clone(); + Poll = other.Poll.Clone(); } public abstract LocalMessageBase Clone(); diff --git a/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollAnswerExtensions.cs b/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollAnswerExtensions.cs new file mode 100644 index 000000000..05beee5fc --- /dev/null +++ b/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollAnswerExtensions.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; + +namespace Disqord; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class LocalPollAnswerExtensions +{ + public static TPollAnswer WithMedia(this TPollAnswer answer, LocalPollMedia media) + where TPollAnswer : LocalPollAnswer + { + answer.Media = media; + return answer; + } +} diff --git a/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollExtensions.cs b/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollExtensions.cs new file mode 100644 index 000000000..0a2d71363 --- /dev/null +++ b/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollExtensions.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.ComponentModel; +using Qommon; + +namespace Disqord; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class LocalPollExtensions +{ + public static TPoll WithQuestion(this TPoll poll, string text) + where TPoll : LocalPoll + { + var media = new LocalPollMedia() + { + Text = text + }; + + return poll.WithQuestion(media); + } + + public static TPoll WithQuestion(this TPoll poll, LocalPollMedia question) + where TPoll : LocalPoll + { + poll.Question = question; + return poll; + } + + public static TPoll AddAnswer(this TPoll poll, string text, LocalEmoji? emoji = null) + where TPoll : LocalPoll + { + var media = new LocalPollMedia + { + Text = text, + Emoji = Optional.FromNullable(emoji) + }; + + var answer = new LocalPollAnswer() + { + Media = media + }; + + return poll.AddAnswer(answer); + } + + public static TPoll AddAnswer(this TPoll poll, LocalPollAnswer answer) + where TPoll : LocalPoll + { + if (poll.Answers.Add(answer, out var list)) + poll.Answers = new(list); + + return poll; + } + + public static TPoll WithAnswers(this TPoll poll, IEnumerable answers) + where TPoll : LocalPoll + { + Guard.IsNotNull(answers); + + if (poll.Answers.With(answers, out var list)) + poll.Answers = new(list); + + return poll; + } + + public static TPoll WithAnswers(this TPoll poll, params LocalPollAnswer[] answers) + where TPoll : LocalPoll + { + return poll.WithAnswers(answers as IEnumerable); + } + + public static TPoll WithDuration(this TPoll poll, int duration) + where TPoll : LocalPoll + { + poll.Duration = duration; + return poll; + } + + public static TPoll WithAllowMultiselect(this TPoll poll, bool allowMultiselect = true) + where TPoll : LocalPoll + { + poll.AllowMultiselect = allowMultiselect; + return poll; + } + + public static TPoll WithLayoutType(this TPoll poll, PollLayoutType layoutType) + where TPoll : LocalPoll + { + poll.LayoutType = layoutType; + return poll; + } +} diff --git a/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollMediaExtensions.cs b/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollMediaExtensions.cs new file mode 100644 index 000000000..8613246d7 --- /dev/null +++ b/src/Disqord.Core/Entities/Local/Message/Poll/Extensions/LocalPollMediaExtensions.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace Disqord; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class LocalPollMediaExtensions +{ + public static TPollMedia WithText(this TPollMedia media, string text) + where TPollMedia : LocalPollMedia + { + media.Text = text; + return media; + } + + public static TPollMedia WithEmoji(this TPollMedia media, LocalEmoji emoji) + where TPollMedia : LocalPollMedia + { + media.Emoji = emoji; + return media; + } +} diff --git a/src/Disqord.Core/Entities/Local/Message/Poll/LocalPoll.cs b/src/Disqord.Core/Entities/Local/Message/Poll/LocalPoll.cs new file mode 100644 index 000000000..0c1eb2d70 --- /dev/null +++ b/src/Disqord.Core/Entities/Local/Message/Poll/LocalPoll.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Disqord.Models; +using Qommon; + +namespace Disqord; + +public class LocalPoll : ILocalConstruct, IJsonConvertible +{ + /// + /// Gets or sets the question of this poll. + /// + public Optional Question { get; set; } + + /// + /// Gets or sets the answers of this poll. + /// + public Optional> Answers { get; set; } + + /// + /// Gets or sets the duration in hours of this poll. + /// + public Optional Duration { get; set; } + + /// + /// Gets or sets whether this poll allows selection of multiple answers. + /// + public Optional AllowMultiselect { get; set; } + + /// + /// Gets or sets the layout type of this poll. + /// + public Optional LayoutType { get; set; } + + /// + /// Instantiates a new . + /// + public LocalPoll() + { } + + /// + /// Instantiates a new with the properties copied from another instance. + /// + /// The other instance to copy properties from. + protected LocalPoll(LocalPoll other) + { + Question = other.Question.Clone(); + Answers = other.Answers.DeepClone(); + Duration = other.Duration; + AllowMultiselect = other.AllowMultiselect; + LayoutType = other.LayoutType; + } + + /// + public virtual LocalPoll Clone() + { + return new(this); + } + + /// + public virtual PollJsonModel ToModel() + { + OptionalGuard.HasValue(Question); + OptionalGuard.HasValue(Answers); + + return new PollJsonModel + { + Question = Question.Value.ToModel(), + Answers = Answers.Value.Select(answer => answer.ToModel()).ToArray(), + Duration = Duration, + AllowMultiselect = AllowMultiselect.GetValueOrDefault(), + LayoutType = LayoutType.GetValueOrDefault(PollLayoutType.Default) + }; + } + + /// + /// Converts the specified poll to a . + /// + /// The poll to convert. + /// + /// The output . + /// + public static LocalPoll CreateFrom(IPoll poll) + { + var localPoll = new LocalPoll + { + Question = LocalPollMedia.CreateFrom(poll.Question), + Answers = poll.Answers.Select(LocalPollAnswer.CreateFrom).ToList(), + AllowMultiselect = poll.AllowMultiselect, + LayoutType = poll.LayoutType + }; + + if (poll.Expiry != null) + { + var now = DateTimeOffset.UtcNow; + if (now < poll.Expiry) + { + localPoll.Duration = (int) Math.Round((poll.Expiry.Value - now).TotalHours); + } + } + + return localPoll; + } +} diff --git a/src/Disqord.Core/Entities/Local/Message/Poll/LocalPollAnswer.cs b/src/Disqord.Core/Entities/Local/Message/Poll/LocalPollAnswer.cs new file mode 100644 index 000000000..20b3a1527 --- /dev/null +++ b/src/Disqord.Core/Entities/Local/Message/Poll/LocalPollAnswer.cs @@ -0,0 +1,61 @@ +using Disqord.Models; +using Qommon; + +namespace Disqord; + +public class LocalPollAnswer : ILocalConstruct, IJsonConvertible +{ + /// + /// Gets or sets the poll media of this poll answer. + /// + public Optional Media { get; set; } + + /// + /// Instantiates a new . + /// + public LocalPollAnswer() + { } + + /// + /// Instantiates a new with the properties copied from another instance. + /// + /// The other instance to copy properties from. + protected LocalPollAnswer(LocalPollAnswer other) + { + Media = other.Media.Clone(); + } + + /// + public virtual LocalPollAnswer Clone() + { + return new(this); + } + + /// + public virtual PollAnswerJsonModel ToModel() + { + OptionalGuard.HasValue(Media); + + return new PollAnswerJsonModel + { + PollMedia = Media.Value.ToModel() + }; + } + + /// + /// Converts the specified poll answer to a . + /// + /// The poll answer to convert. + /// + /// The output . + /// + public static LocalPollAnswer CreateFrom(IPollAnswer pollAnswer) + { + var localPoll = new LocalPollAnswer + { + Media = LocalPollMedia.CreateFrom(pollAnswer.Media) + }; + + return localPoll; + } +} diff --git a/src/Disqord.Core/Entities/Local/Message/Poll/LocalPollMedia.cs b/src/Disqord.Core/Entities/Local/Message/Poll/LocalPollMedia.cs new file mode 100644 index 000000000..401ab860d --- /dev/null +++ b/src/Disqord.Core/Entities/Local/Message/Poll/LocalPollMedia.cs @@ -0,0 +1,67 @@ +using Disqord.Models; +using Qommon; + +namespace Disqord; + +public class LocalPollMedia : ILocalConstruct, IJsonConvertible +{ + /// + /// Gets or sets the text of this poll media. + /// + public Optional Text { get; set; } + + /// + /// Gets or sets the emoji of this poll media. + /// + public Optional Emoji { get; set; } + + /// + /// Instantiates a new . + /// + public LocalPollMedia() + { } + + /// + /// Instantiates a new with the properties copied from another instance. + /// + /// The other instance to copy properties from. + protected LocalPollMedia(LocalPollMedia other) + { + Text = other.Text; + Emoji = other.Emoji.Clone(); + } + + /// + public virtual LocalPollMedia Clone() + { + return new(this); + } + + /// + public virtual PollMediaJsonModel ToModel() + { + return new PollMediaJsonModel + { + Text = Text, + Emoji = Optional.Convert(Emoji, emoji => emoji.ToModel()) + }; + } + + /// + /// Converts the specified poll media to a . + /// + /// The poll media to convert. + /// + /// The output . + /// + public static LocalPollMedia CreateFrom(IPollMedia pollMedia) + { + var localPoll = new LocalPollMedia + { + Text = Optional.FromNullable(pollMedia.Text), + Emoji = Optional.FromNullable(LocalEmoji.FromEmoji(pollMedia.Emoji)) + }; + + return localPoll; + } +} diff --git a/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPoll.cs b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPoll.cs new file mode 100644 index 000000000..4ebb3677b --- /dev/null +++ b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPoll.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using Disqord.Models; +using Qommon; +using Qommon.Collections.ReadOnly; + +namespace Disqord; + +public class TransientPoll(PollJsonModel model) : TransientEntity(model), IPoll +{ + public IPollMedia Question => _question ??= new TransientPollMedia(Model.Question); + + private IPollMedia? _question; + + public IReadOnlyList Answers => _answers ??= Model.Answers.ToReadOnlyList(answer => new TransientPollAnswer(answer)); + + private IReadOnlyList? _answers; + + public DateTimeOffset? Expiry => Model.Expiry.GetValueOrDefault(); + + public bool AllowMultiselect => Model.AllowMultiselect; + + public PollLayoutType LayoutType => Model.LayoutType; + + public IPollResults? Results => _results ??= Optional.ConvertOrDefault(Model.Results, results => new TransientPollResults(results)); + + private IPollResults? _results; +} diff --git a/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollAnswer.cs b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollAnswer.cs new file mode 100644 index 000000000..13d90a234 --- /dev/null +++ b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollAnswer.cs @@ -0,0 +1,12 @@ +using Disqord.Models; + +namespace Disqord; + +public class TransientPollAnswer(PollAnswerJsonModel model) : TransientEntity(model), IPollAnswer +{ + public int Id => Model.AnswerId.Value; + + public IPollMedia Media => _media ??= new TransientPollMedia(Model.PollMedia); + + private IPollMedia? _media; +} diff --git a/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollAnswerCount.cs b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollAnswerCount.cs new file mode 100644 index 000000000..da4ce42dd --- /dev/null +++ b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollAnswerCount.cs @@ -0,0 +1,12 @@ +using Disqord.Models; + +namespace Disqord; + +public class TransientPollAnswerCount(PollAnswerCountsJsonModel model) : TransientEntity(model), IPollAnswerCount +{ + public int AnswerId => Model.Id; + + public int Count => Model.Count; + + public bool HasOwnVote => Model.MeVoted; +} diff --git a/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollMedia.cs b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollMedia.cs new file mode 100644 index 000000000..d81c2117a --- /dev/null +++ b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollMedia.cs @@ -0,0 +1,13 @@ +using Disqord.Models; +using Qommon; + +namespace Disqord; + +public class TransientPollMedia(PollMediaJsonModel model) : TransientEntity(model), IPollMedia +{ + public string? Text => Model.Text.GetValueOrDefault(); + + public IEmoji? Emoji => _emoji ??= Optional.ConvertOrDefault(Model.Emoji, TransientEmoji.Create); + + private IEmoji? _emoji; +} diff --git a/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollResults.cs b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollResults.cs new file mode 100644 index 000000000..aa1ca53a7 --- /dev/null +++ b/src/Disqord.Core/Entities/Transient/Message/User/Poll/TransientPollResults.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Disqord.Models; +using Qommon.Collections.ReadOnly; + +namespace Disqord; + +public class TransientPollResults(PollResultsJsonModel model) : TransientEntity(model), IPollResults +{ + public bool IsFinalized => Model.IsFinalized; + + public IReadOnlyList AnswerCounts => _answerCounts ??= Model.AnswerCounts.ToReadOnlyList(count => new TransientPollAnswerCount(count)); + + private IReadOnlyList? _answerCounts; +} diff --git a/src/Disqord.Core/Entities/Transient/Message/User/TransientUserMessage.cs b/src/Disqord.Core/Entities/Transient/Message/User/TransientUserMessage.cs index 92792cd7c..24cd307d0 100644 --- a/src/Disqord.Core/Entities/Transient/Message/User/TransientUserMessage.cs +++ b/src/Disqord.Core/Entities/Transient/Message/User/TransientUserMessage.cs @@ -125,8 +125,14 @@ public IReadOnlyList Stickers return _stickers ??= Model.StickerItems.Value.ToReadOnlyList(model => new TransientMessageSticker(model)); } } + private IReadOnlyList? _stickers; + /// + public IPoll? Poll => _poll ??= Optional.ConvertOrDefault(Model.Poll, poll => new TransientPoll(poll)); + + private IPoll? _poll; + public TransientUserMessage(IClient client, MessageJsonModel model) : base(client, model) { } diff --git a/src/Disqord.Core/Enums/Permissions.cs b/src/Disqord.Core/Enums/Permissions.cs index 35e412a3c..164682d98 100644 --- a/src/Disqord.Core/Enums/Permissions.cs +++ b/src/Disqord.Core/Enums/Permissions.cs @@ -261,6 +261,11 @@ public enum Permissions : ulong /// SendVoiceMessages = 1ul << 46, + /// + /// Allows sending polls. + /// + SendPolls = 1ul << 49, + /// /// Represents all permissions combined together. /// @@ -278,5 +283,5 @@ public enum Permissions : ulong | RequestToSpeak | ManageEvents | ManageThreads | CreatePublicThreads | CreatePrivateThreads | UseExternalStickers | SendMessagesInThreads | StartActivities | ModerateMembers | ViewCreatorMonetizationAnalytics | UseSoundboard | CreateExpressions - | CreateEvents | UseExternalSounds | SendVoiceMessages + | CreateEvents | UseExternalSounds | SendVoiceMessages | SendPolls } diff --git a/src/Disqord.Core/Enums/PollLayoutType.cs b/src/Disqord.Core/Enums/PollLayoutType.cs new file mode 100644 index 000000000..a86dd7645 --- /dev/null +++ b/src/Disqord.Core/Enums/PollLayoutType.cs @@ -0,0 +1,6 @@ +namespace Disqord; + +public enum PollLayoutType +{ + Default = 1 +} diff --git a/src/Disqord.Core/Models/Message/MessageJsonModel.cs b/src/Disqord.Core/Models/Message/MessageJsonModel.cs index ffc1d5417..57184e066 100644 --- a/src/Disqord.Core/Models/Message/MessageJsonModel.cs +++ b/src/Disqord.Core/Models/Message/MessageJsonModel.cs @@ -97,4 +97,7 @@ public class MessageJsonModel : JsonModel [JsonProperty("sticker_items")] public Optional StickerItems; + + [JsonProperty("poll")] + public Optional Poll; } diff --git a/src/Disqord.Core/Models/Message/Poll/PollAnswerCountsJsonModel.cs b/src/Disqord.Core/Models/Message/Poll/PollAnswerCountsJsonModel.cs new file mode 100644 index 000000000..83d6e1f8b --- /dev/null +++ b/src/Disqord.Core/Models/Message/Poll/PollAnswerCountsJsonModel.cs @@ -0,0 +1,15 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Models; + +public class PollAnswerCountsJsonModel : JsonModel +{ + [JsonProperty("id")] + public int Id; + + [JsonProperty("count")] + public int Count; + + [JsonProperty("me_voted")] + public bool MeVoted; +} diff --git a/src/Disqord.Core/Models/Message/Poll/PollAnswerJsonModel.cs b/src/Disqord.Core/Models/Message/Poll/PollAnswerJsonModel.cs new file mode 100644 index 000000000..ca18cee64 --- /dev/null +++ b/src/Disqord.Core/Models/Message/Poll/PollAnswerJsonModel.cs @@ -0,0 +1,13 @@ +using Disqord.Serialization.Json; +using Qommon; + +namespace Disqord.Models; + +public class PollAnswerJsonModel : JsonModel +{ + [JsonProperty("answer_id")] + public Optional AnswerId; + + [JsonProperty("poll_media")] + public PollMediaJsonModel PollMedia = null!; +} diff --git a/src/Disqord.Core/Models/Message/Poll/PollJsonModel.cs b/src/Disqord.Core/Models/Message/Poll/PollJsonModel.cs new file mode 100644 index 000000000..edbaf090c --- /dev/null +++ b/src/Disqord.Core/Models/Message/Poll/PollJsonModel.cs @@ -0,0 +1,29 @@ +using System; +using Disqord.Serialization.Json; +using Qommon; + +namespace Disqord.Models; + +public class PollJsonModel : JsonModel +{ + [JsonProperty("question")] + public PollMediaJsonModel Question = null!; + + [JsonProperty("answers")] + public PollAnswerJsonModel[] Answers = null!; + + [JsonProperty("expiry")] + public Optional Expiry; + + [JsonProperty("duration")] + public Optional Duration; + + [JsonProperty("allow_multiselect")] + public bool AllowMultiselect; + + [JsonProperty("layout_type")] + public PollLayoutType LayoutType; + + [JsonProperty("results")] + public Optional Results; +} diff --git a/src/Disqord.Core/Models/Message/Poll/PollMediaJsonModel.cs b/src/Disqord.Core/Models/Message/Poll/PollMediaJsonModel.cs new file mode 100644 index 000000000..4091690f3 --- /dev/null +++ b/src/Disqord.Core/Models/Message/Poll/PollMediaJsonModel.cs @@ -0,0 +1,13 @@ +using Disqord.Serialization.Json; +using Qommon; + +namespace Disqord.Models; + +public class PollMediaJsonModel : JsonModel +{ + [JsonProperty("text")] + public Optional Text; + + [JsonProperty("emoji")] + public Optional Emoji; +} diff --git a/src/Disqord.Core/Models/Message/Poll/PollResultsJsonModel.cs b/src/Disqord.Core/Models/Message/Poll/PollResultsJsonModel.cs new file mode 100644 index 000000000..5b1aee0c6 --- /dev/null +++ b/src/Disqord.Core/Models/Message/Poll/PollResultsJsonModel.cs @@ -0,0 +1,12 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Models; + +public class PollResultsJsonModel : JsonModel +{ + [JsonProperty("is_finalized")] + public bool IsFinalized; + + [JsonProperty("answer_counts")] + public PollAnswerCountsJsonModel[] AnswerCounts = null!; +} diff --git a/src/Disqord.Gateway.Api/GatewayIntents.cs b/src/Disqord.Gateway.Api/GatewayIntents.cs index 467cc4d31..0893bb029 100644 --- a/src/Disqord.Gateway.Api/GatewayIntents.cs +++ b/src/Disqord.Gateway.Api/GatewayIntents.cs @@ -126,6 +126,16 @@ public enum GatewayIntents : ulong /// AutoModerationExecution = 1 << 21, + /// + /// Allows receiving guild message poll events. + /// + GuildPolls = 1 << 24, + + /// + /// Allows receiving message poll events for direct channels. + /// + DirectPolls = 1 << 25, + /// /// Represents all unprivileged intents, /// i.e. intents that never require bot verification. @@ -133,7 +143,8 @@ public enum GatewayIntents : ulong Unprivileged = Guilds | Moderation | EmojisAndStickers | Integrations | Webhooks | Invites | VoiceStates | GuildMessages | GuildReactions | GuildTyping | DirectMessages | DirectReactions - | DirectTyping | GuildEvents | AutoModerationConfiguration | AutoModerationExecution, + | DirectTyping | GuildEvents | AutoModerationConfiguration | AutoModerationExecution + | GuildPolls | DirectPolls, /// /// Represents all privileged intents, @@ -147,11 +158,13 @@ public enum GatewayIntents : ulong /// /// Includes and /// which are privileged intents. + /// + /// Does not include direct channel intents. /// LibraryRecommended = Guilds | Members | Moderation | EmojisAndStickers | Integrations | Webhooks | Invites | VoiceStates | GuildMessages | GuildReactions | MessageContent | GuildEvents - | AutoModerationConfiguration | AutoModerationExecution, + | AutoModerationConfiguration | AutoModerationExecution | GuildPolls, /// /// Represents all intents. @@ -160,5 +173,6 @@ public enum GatewayIntents : ulong | Integrations | Webhooks | Invites | VoiceStates | Presences | GuildMessages | GuildReactions | GuildTyping | DirectMessages | DirectReactions | DirectTyping | MessageContent - | GuildEvents | AutoModerationConfiguration | AutoModerationExecution, + | GuildEvents | AutoModerationConfiguration | AutoModerationExecution + | GuildPolls | DirectPolls, } diff --git a/src/Disqord.Gateway.Api/Models/Payloads/MessagePollVoteAddJsonModel.cs b/src/Disqord.Gateway.Api/Models/Payloads/MessagePollVoteAddJsonModel.cs new file mode 100644 index 000000000..1e94342ba --- /dev/null +++ b/src/Disqord.Gateway.Api/Models/Payloads/MessagePollVoteAddJsonModel.cs @@ -0,0 +1,22 @@ +using Disqord.Serialization.Json; +using Qommon; + +namespace Disqord.Gateway.Api.Models; + +public class MessagePollVoteAddJsonModel : JsonModel +{ + [JsonProperty("user_id")] + public Snowflake UserId; + + [JsonProperty("channel_id")] + public Snowflake ChannelId; + + [JsonProperty("message_id")] + public Snowflake MessageId; + + [JsonProperty("guild_id")] + public Optional GuildId; + + [JsonProperty("answer_id")] + public int AnswerId; +} diff --git a/src/Disqord.Gateway.Api/Models/Payloads/MessagePollVoteRemoveJsonModel.cs b/src/Disqord.Gateway.Api/Models/Payloads/MessagePollVoteRemoveJsonModel.cs new file mode 100644 index 000000000..d02d922da --- /dev/null +++ b/src/Disqord.Gateway.Api/Models/Payloads/MessagePollVoteRemoveJsonModel.cs @@ -0,0 +1,22 @@ +using Disqord.Serialization.Json; +using Qommon; + +namespace Disqord.Gateway.Api.Models; + +public class MessagePollVoteRemoveJsonModel : JsonModel +{ + [JsonProperty("user_id")] + public Snowflake UserId; + + [JsonProperty("channel_id")] + public Snowflake ChannelId; + + [JsonProperty("message_id")] + public Snowflake MessageId; + + [JsonProperty("guild_id")] + public Optional GuildId; + + [JsonProperty("answer_id")] + public int AnswerId; +} diff --git a/src/Disqord.Gateway.Api/Models/Payloads/MessageUpdateJsonModel.cs b/src/Disqord.Gateway.Api/Models/Payloads/MessageUpdateJsonModel.cs index b068e4c1f..c63cb9cbf 100644 --- a/src/Disqord.Gateway.Api/Models/Payloads/MessageUpdateJsonModel.cs +++ b/src/Disqord.Gateway.Api/Models/Payloads/MessageUpdateJsonModel.cs @@ -89,4 +89,7 @@ public class MessageUpdateJsonModel : JsonModel [JsonProperty("sticker_items")] public Optional StickerItems; + + [JsonProperty("poll")] + public Optional Poll; } diff --git a/src/Disqord.Gateway/Default/DefaultGatewayClient.Events.cs b/src/Disqord.Gateway/Default/DefaultGatewayClient.Events.cs index 755c24333..fabbc045e 100644 --- a/src/Disqord.Gateway/Default/DefaultGatewayClient.Events.cs +++ b/src/Disqord.Gateway/Default/DefaultGatewayClient.Events.cs @@ -382,6 +382,20 @@ public event AsynchronousEventHandler StageDeleted remove => Dispatcher.StageDeletedEvent.Remove(value); } + /// + public event AsynchronousEventHandler PollVoteAdded + { + add => Dispatcher.PollVoteAddedEvent.Add(value); + remove => Dispatcher.PollVoteAddedEvent.Remove(value); + } + + /// + public event AsynchronousEventHandler PollVoteRemoved + { + add => Dispatcher.PollVoteRemovedEvent.Add(value); + remove => Dispatcher.PollVoteRemovedEvent.Remove(value); + } + /// public event AsynchronousEventHandler TypingStarted { diff --git a/src/Disqord.Gateway/Default/Dispatcher/DefaultGatewayDispatcher.Events.cs b/src/Disqord.Gateway/Default/Dispatcher/DefaultGatewayDispatcher.Events.cs index 09e14cbcc..217970849 100644 --- a/src/Disqord.Gateway/Default/Dispatcher/DefaultGatewayDispatcher.Events.cs +++ b/src/Disqord.Gateway/Default/Dispatcher/DefaultGatewayDispatcher.Events.cs @@ -112,6 +112,10 @@ public partial class DefaultGatewayDispatcher public AsynchronousEvent StageDeletedEvent { get; } = new(); + public AsynchronousEvent PollVoteAddedEvent { get; } = new(); + + public AsynchronousEvent PollVoteRemovedEvent { get; } = new(); + public AsynchronousEvent TypingStartedEvent { get; } = new(); public AsynchronousEvent CurrentUserUpdatedEvent { get; } = new(); diff --git a/src/Disqord.Gateway/Default/Dispatcher/DefaultGatewayDispatcher.cs b/src/Disqord.Gateway/Default/Dispatcher/DefaultGatewayDispatcher.cs index c1987362b..8414c9f2d 100644 --- a/src/Disqord.Gateway/Default/Dispatcher/DefaultGatewayDispatcher.cs +++ b/src/Disqord.Gateway/Default/Dispatcher/DefaultGatewayDispatcher.cs @@ -178,6 +178,9 @@ public DefaultGatewayDispatcher( [GatewayDispatchNames.StageInstanceUpdate] = new StageUpdateDispatchHandler(), [GatewayDispatchNames.StageInstanceDelete] = new StageDeleteDispatchHandler(), + [GatewayDispatchNames.MessagePollVoteAdd] = new MessagePollVoteAddDispatchHandler(), + [GatewayDispatchNames.MessagePollVoteRemove] = new MessagePollVoteRemoveDispatchHandler(), + [GatewayDispatchNames.TypingStart] = new TypingStartDispatchHandler(), [GatewayDispatchNames.UserUpdate] = new UserUpdateDispatchHandler(), diff --git a/src/Disqord.Gateway/Default/Dispatcher/Dispatches/Polls/MESSAGE_POLL_VOTE_ADD.cs b/src/Disqord.Gateway/Default/Dispatcher/Dispatches/Polls/MESSAGE_POLL_VOTE_ADD.cs new file mode 100644 index 000000000..5abd24a5b --- /dev/null +++ b/src/Disqord.Gateway/Default/Dispatcher/Dispatches/Polls/MESSAGE_POLL_VOTE_ADD.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using Disqord.Gateway.Api; +using Disqord.Gateway.Api.Models; +using Qommon; + +namespace Disqord.Gateway.Default.Dispatcher; + +public class MessagePollVoteAddDispatchHandler : DispatchHandler +{ + public override ValueTask HandleDispatchAsync(IShard shard, MessagePollVoteAddJsonModel model) + { + var e = new PollVoteAddedEventArgs(model.GuildId.GetValueOrNullable(), model.UserId, model.ChannelId, model.MessageId, model.AnswerId); + return new(e); + } +} diff --git a/src/Disqord.Gateway/Default/Dispatcher/Dispatches/Polls/MESSAGE_POLL_VOTE_REMOVE.cs b/src/Disqord.Gateway/Default/Dispatcher/Dispatches/Polls/MESSAGE_POLL_VOTE_REMOVE.cs new file mode 100644 index 000000000..5eb18d9b0 --- /dev/null +++ b/src/Disqord.Gateway/Default/Dispatcher/Dispatches/Polls/MESSAGE_POLL_VOTE_REMOVE.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using Disqord.Gateway.Api; +using Disqord.Gateway.Api.Models; +using Qommon; + +namespace Disqord.Gateway.Default.Dispatcher; + +public class MessagePollVoteRemoveDispatchHandler : DispatchHandler +{ + public override ValueTask HandleDispatchAsync(IShard shard, MessagePollVoteRemoveJsonModel model) + { + var e = new PollVoteRemovedEventArgs(model.GuildId.GetValueOrNullable(), model.UserId, model.ChannelId, model.MessageId, model.AnswerId); + return new(e); + } +} diff --git a/src/Disqord.Gateway/Default/Dispatcher/EventArgs/Polls/PollVoteAddedEventArgs.cs b/src/Disqord.Gateway/Default/Dispatcher/EventArgs/Polls/PollVoteAddedEventArgs.cs new file mode 100644 index 000000000..f2c73371f --- /dev/null +++ b/src/Disqord.Gateway/Default/Dispatcher/EventArgs/Polls/PollVoteAddedEventArgs.cs @@ -0,0 +1,46 @@ +using System; + +namespace Disqord.Gateway; + +public class PollVoteAddedEventArgs : EventArgs +{ + /// + /// Gets the ID of the guild in which the poll vote was added. + /// Returns if it was added in a private channel. + /// + public Snowflake? GuildId { get; } + + /// + /// Gets the ID of the user who added the poll vote. + /// + public Snowflake UserId { get; } + + /// + /// Gets the ID of the channel in which the poll vote was added. + /// + public Snowflake ChannelId { get; } + + /// + /// Gets the ID of the message from which the poll vote was added. + /// + public Snowflake MessageId { get; } + + /// + /// Gets the ID of the poll answer from which the vote was added. + /// + public int AnswerId { get; } + + public PollVoteAddedEventArgs( + Snowflake? guildId, + Snowflake userId, + Snowflake channelId, + Snowflake messageId, + int answerId) + { + GuildId = guildId; + UserId = userId; + ChannelId = channelId; + MessageId = messageId; + AnswerId = answerId; + } +} diff --git a/src/Disqord.Gateway/Default/Dispatcher/EventArgs/Polls/PollVoteRemovedEventArgs.cs b/src/Disqord.Gateway/Default/Dispatcher/EventArgs/Polls/PollVoteRemovedEventArgs.cs new file mode 100644 index 000000000..2bd21c4d5 --- /dev/null +++ b/src/Disqord.Gateway/Default/Dispatcher/EventArgs/Polls/PollVoteRemovedEventArgs.cs @@ -0,0 +1,46 @@ +using System; + +namespace Disqord.Gateway; + +public class PollVoteRemovedEventArgs : EventArgs +{ + /// + /// Gets the ID of the guild in which the poll vote was removed. + /// Returns if it was removed in a private channel. + /// + public Snowflake? GuildId { get; } + + /// + /// Gets the ID of the user who removed the poll vote. + /// + public Snowflake UserId { get; } + + /// + /// Gets the ID of the channel in which the poll vote was removed. + /// + public Snowflake ChannelId { get; } + + /// + /// Gets the ID of the message from which the poll vote was removed. + /// + public Snowflake MessageId { get; } + + /// + /// Gets the ID of the poll answer from which the vote was removed. + /// + public int AnswerId { get; } + + public PollVoteRemovedEventArgs( + Snowflake? guildId, + Snowflake userId, + Snowflake channelId, + Snowflake messageId, + int answerId) + { + GuildId = guildId; + UserId = userId; + ChannelId = channelId; + MessageId = messageId; + AnswerId = answerId; + } +} diff --git a/src/Disqord.Gateway/Entities/Cached/Message/User/CachedUserMessage.cs b/src/Disqord.Gateway/Entities/Cached/Message/User/CachedUserMessage.cs index 6bb98763f..ddec37414 100644 --- a/src/Disqord.Gateway/Entities/Cached/Message/User/CachedUserMessage.cs +++ b/src/Disqord.Gateway/Entities/Cached/Message/User/CachedUserMessage.cs @@ -64,6 +64,9 @@ public class CachedUserMessage : CachedMessage, IGatewayUserMessage, IJsonUpdata /// public IReadOnlyList Stickers { get; private set; } = null!; + /// + public IPoll? Poll { get; private set; } + public CachedUserMessage(IGatewayClient client, CachedMember? author, MessageJsonModel model) : base(client, author, model) { @@ -92,6 +95,7 @@ public override void Update(MessageJsonModel model) Interaction = Optional.ConvertOrDefault(model.Interaction, (model, client) => new TransientMessageInteraction(new TransientUser(client, model.User), model), Client); Components = Optional.ConvertOrDefault(model.Components, (models, client) => models.ToReadOnlyList(client, (model, client) => new TransientRowComponent(client, model) as IRowComponent), Client) ?? Array.Empty(); Stickers = Optional.ConvertOrDefault(model.StickerItems, models => models.ToReadOnlyList(model => new TransientMessageSticker(model) as IMessageSticker), Array.Empty()); + Poll = Optional.ConvertOrDefault(model.Poll, model => new TransientPoll(model)); } public void Update(MessageUpdateJsonModel model) @@ -166,5 +170,8 @@ public void Update(MessageUpdateJsonModel model) if (model.StickerItems.HasValue) Stickers = Optional.ConvertOrDefault(model.StickerItems, models => models.ToReadOnlyList(model => new TransientMessageSticker(model) as IMessageSticker), Array.Empty()); + + if (model.Poll.HasValue) + Poll = Optional.ConvertOrDefault(model.Poll, model => new TransientPoll(model)); } } diff --git a/src/Disqord.Gateway/Entities/Transient/Message/User/TransientGatewayUserMessage.cs b/src/Disqord.Gateway/Entities/Transient/Message/User/TransientGatewayUserMessage.cs index bb7f5e224..d0af45326 100644 --- a/src/Disqord.Gateway/Entities/Transient/Message/User/TransientGatewayUserMessage.cs +++ b/src/Disqord.Gateway/Entities/Transient/Message/User/TransientGatewayUserMessage.cs @@ -98,8 +98,14 @@ public IReadOnlyList Stickers return _stickers ??= Model.StickerItems.Value.ToReadOnlyList(model => new TransientMessageSticker(model)); } } + private IReadOnlyList? _stickers; + /// + public IPoll? Poll => _poll ??= Optional.ConvertOrDefault(Model.Poll, poll => new TransientPoll(poll)); + + private IPoll? _poll; + public TransientGatewayUserMessage(IClient client, MessageJsonModel model) : base(client, model) { } diff --git a/src/Disqord.Gateway/GatewayDispatchNames.cs b/src/Disqord.Gateway/GatewayDispatchNames.cs index daed01ddf..c251c154c 100644 --- a/src/Disqord.Gateway/GatewayDispatchNames.cs +++ b/src/Disqord.Gateway/GatewayDispatchNames.cs @@ -280,6 +280,16 @@ public static class GatewayDispatchNames /// public const string StageInstanceDelete = "STAGE_INSTANCE_DELETE"; + /// + /// The MESSAGE_POLL_VOTE_ADD dispatch. + /// + public const string MessagePollVoteAdd = "MESSAGE_POLL_VOTE_ADD"; + + /// + /// The MESSAGE_POLL_VOTE_REMOVE dispatch. + /// + public const string MessagePollVoteRemove = "MESSAGE_POLL_VOTE_REMOVE"; + /// /// The TYPING_START dispatch. /// diff --git a/src/Disqord.Gateway/IGatewayClient.Events.cs b/src/Disqord.Gateway/IGatewayClient.Events.cs index 030fafa04..b010d3eca 100644 --- a/src/Disqord.Gateway/IGatewayClient.Events.cs +++ b/src/Disqord.Gateway/IGatewayClient.Events.cs @@ -292,6 +292,16 @@ public partial interface IGatewayClient /// event AsynchronousEventHandler StageDeleted; + /// + /// Fires when a reaction is added to a message. + /// + event AsynchronousEventHandler PollVoteAdded; + + /// + /// Fires when a reaction is removed from a message. + /// + event AsynchronousEventHandler PollVoteRemoved; + /// /// Fires when a user starts typing in a channel. /// diff --git a/src/Disqord.Gateway/IGatewayDispatcher.Events.cs b/src/Disqord.Gateway/IGatewayDispatcher.Events.cs index 0070e9880..b7e70476b 100644 --- a/src/Disqord.Gateway/IGatewayDispatcher.Events.cs +++ b/src/Disqord.Gateway/IGatewayDispatcher.Events.cs @@ -112,6 +112,10 @@ public partial interface IGatewayDispatcher AsynchronousEvent StageDeletedEvent { get; } + AsynchronousEvent PollVoteAddedEvent { get; } + + AsynchronousEvent PollVoteRemovedEvent { get; } + AsynchronousEvent TypingStartedEvent { get; } AsynchronousEvent CurrentUserUpdatedEvent { get; } diff --git a/src/Disqord.Rest.Api/Content/Json/CreateMessageJsonRestRequestContent.cs b/src/Disqord.Rest.Api/Content/Json/CreateMessageJsonRestRequestContent.cs index c80551574..0c542ce9b 100644 --- a/src/Disqord.Rest.Api/Content/Json/CreateMessageJsonRestRequestContent.cs +++ b/src/Disqord.Rest.Api/Content/Json/CreateMessageJsonRestRequestContent.cs @@ -40,6 +40,9 @@ public class CreateMessageJsonRestRequestContent : JsonModelRestRequestContent, [JsonProperty("enforce_nonce")] public Optional EnforceNonce; + [JsonProperty("poll")] + public Optional Poll; + IList IAttachmentRestRequestContent.Attachments { set => Attachments = new(value); @@ -84,5 +87,10 @@ protected override void OnValidate() for (var i = 0; i < components.Length; i++) components[i].Validate(); }); + + OptionalGuard.CheckValue(Poll, pollJsonModel => + { + pollJsonModel.Validate(); + }); } } diff --git a/src/Disqord.Rest.Api/Methods/RestApiClientExtensions.Poll.cs b/src/Disqord.Rest.Api/Methods/RestApiClientExtensions.Poll.cs new file mode 100644 index 000000000..1822b0315 --- /dev/null +++ b/src/Disqord.Rest.Api/Methods/RestApiClientExtensions.Poll.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Disqord.Models; +using Qommon; + +namespace Disqord.Rest.Api; + +public static partial class RestApiClientExtensions +{ + public static Task FetchAnswerVotersAsync(this IRestApiClient client, + Snowflake channelId, Snowflake messageId, int answerId, + int limit = Discord.Limits.Rest.FetchPollAnswerVotersPageSize, Snowflake? startFromId = null, + IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + Guard.IsBetweenOrEqualTo(limit, 0, Discord.Limits.Rest.FetchPollAnswerVotersPageSize); + + var queryParameters = new Dictionary(startFromId != null ? 2 : 1) + { + ["limit"] = limit + }; + + if (startFromId != null) + queryParameters["after"] = startFromId; + + var route = Format(Route.Poll.GetAnswerVoters, queryParameters, channelId, messageId, answerId); + return client.ExecuteAsync(route, null, options, cancellationToken); + } + + public static Task EndPollAsync(this IRestApiClient client, + Snowflake channelId, Snowflake messageId, + IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + var route = Format(Route.Poll.EndPoll, channelId, messageId); + return client.ExecuteAsync(route, null, options, cancellationToken); + } +} diff --git a/src/Disqord.Rest.Api/Requests/Routing/Default/Route.Static.cs b/src/Disqord.Rest.Api/Requests/Routing/Default/Route.Static.cs index c3f201b7c..81ba04d41 100644 --- a/src/Disqord.Rest.Api/Requests/Routing/Default/Route.Static.cs +++ b/src/Disqord.Rest.Api/Requests/Routing/Default/Route.Static.cs @@ -257,6 +257,13 @@ public static class Invite public static readonly Route DeleteInvite = Delete("invites/{0:invite_code}"); } + public static class Poll + { + public static readonly Route GetAnswerVoters = Get("channels/{0:channel_id}/polls/{1:message_id}/answers/{2:answer_id}"); + + public static readonly Route EndPoll = Post("channels/{0:channel_id}/polls/{1:message_id}/expire"); + } + public static class Stages { public static readonly Route CreateStage = Post("stage-instances"); diff --git a/src/Disqord.Rest.Api/RestApiErrorCode.cs b/src/Disqord.Rest.Api/RestApiErrorCode.cs index cf1cba4e7..ca86647a4 100644 --- a/src/Disqord.Rest.Api/RestApiErrorCode.cs +++ b/src/Disqord.Rest.Api/RestApiErrorCode.cs @@ -443,4 +443,16 @@ public enum RestApiErrorCode WebhookServicesCannotBeUsedInForumChannels = 220004, MessageBlockedByHarmfulLinksFilter = 240000, + + PollVotingBlocked = 520000, + + PollExpired = 520001, + + InvalidChannelTypeForPollCreation = 520002, + + CannotEditAPollMessage = 520003, + + CannotUseAnEmojiIncludedWithThePoll = 520004, + + CannotExpireANonPollMessage = 520006, } diff --git a/src/Disqord.Rest/Extensions/Entity/RestEntityExtensions.Channel.cs b/src/Disqord.Rest/Extensions/Entity/RestEntityExtensions.Channel.cs index 2a8d0462c..e7f7aac37 100644 --- a/src/Disqord.Rest/Extensions/Entity/RestEntityExtensions.Channel.cs +++ b/src/Disqord.Rest/Extensions/Entity/RestEntityExtensions.Channel.cs @@ -284,8 +284,8 @@ public static Task> FetchWebhooksAsync(this IMessageChan } /* - * Threads - */ + * Threads + */ public static Task CreatePublicThreadAsync(this ITextChannel channel, string name, Snowflake? messageId = null, Action? action = null, IRestRequestOptions? options = null, CancellationToken cancellationToken = default) @@ -432,4 +432,31 @@ public static Task CreateStageAsync(this IStageChannel channel, var client = channel.GetRestClient(); return client.FetchStageAsync(channel.Id, options, cancellationToken); } + + /* + * Polls + */ + public static IPagedEnumerable EnumeratePollAnswerVoters(this IMessageChannel channel, + Snowflake messageId, int answerId, int limit, Snowflake? startFromId = null, + IRestRequestOptions? options = null) + { + var client = channel.GetRestClient(); + return client.EnumeratePollAnswerVoters(channel.Id, messageId, answerId, limit, startFromId, options); + } + + public static Task> FetchPollAnswerVotersAsync(this IMessageChannel channel, + Snowflake messageId, int answerId, int limit = Discord.Limits.Rest.FetchPollAnswerVotersPageSize, Snowflake? startFromId = null, + IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + var client = channel.GetRestClient(); + return client.FetchPollAnswerVotersAsync(channel.Id, messageId, answerId, limit, startFromId, options, cancellationToken); + } + + public static Task EndPollAsync(this IMessageChannel channel, + Snowflake messageId, + IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + var client = channel.GetRestClient(); + return client.EndPollAsync(channel.Id, messageId, options, cancellationToken); + } } diff --git a/src/Disqord.Rest/Extensions/Entity/RestEntityExtensions.Message.cs b/src/Disqord.Rest/Extensions/Entity/RestEntityExtensions.Message.cs index c9c1ae3fe..621f2f374 100644 --- a/src/Disqord.Rest/Extensions/Entity/RestEntityExtensions.Message.cs +++ b/src/Disqord.Rest/Extensions/Entity/RestEntityExtensions.Message.cs @@ -91,4 +91,30 @@ public static Task UnpinAsync(this IUserMessage message, var client = message.GetRestClient(); return client.UnpinMessageAsync(message.ChannelId, message.Id, options, cancellationToken); } + + /* + * Polls + */ + public static IPagedEnumerable EnumeratePollAnswerVoters(this IUserMessage message, + int answerId, int limit, Snowflake? startFromId = null, + IRestRequestOptions? options = null) + { + var client = message.GetRestClient(); + return client.EnumeratePollAnswerVoters(message.ChannelId, message.Id, answerId, limit, startFromId, options); + } + + public static Task> FetchPollAnswerVotersAsync(this IUserMessage message, + int answerId, int limit = Discord.Limits.Rest.FetchPollAnswerVotersPageSize, Snowflake? startFromId = null, + IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + var client = message.GetRestClient(); + return client.FetchPollAnswerVotersAsync(message.ChannelId, message.Id, answerId, limit, startFromId, options, cancellationToken); + } + + public static Task EndPollAsync(this IUserMessage message, + IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + var client = message.GetRestClient(); + return client.EndPollAsync(message.ChannelId, message.Id, options, cancellationToken); + } } diff --git a/src/Disqord.Rest/Extensions/RestClientExtensions.Channel.cs b/src/Disqord.Rest/Extensions/RestClientExtensions.Channel.cs index a7966fe0c..ce85774f9 100644 --- a/src/Disqord.Rest/Extensions/RestClientExtensions.Channel.cs +++ b/src/Disqord.Rest/Extensions/RestClientExtensions.Channel.cs @@ -262,6 +262,7 @@ public static async Task SendMessageAsync(this IRestClient client, Components = Optional.Convert(message.Components, components => components.Select(component => component.ToModel()).ToArray()), StickerIds = Optional.Convert(message.StickerIds, stickerIds => stickerIds.ToArray()), Flags = message.Flags, + Poll = Optional.Convert(message.Poll, poll => poll.ToModel()), EnforceNonce = message.ShouldEnforceNonce }; @@ -651,6 +652,7 @@ public static async Task CreateForumThreadAsync(this IRestClient Components = Optional.Convert(message.Components, components => components.Select(component => component.ToModel()).ToArray()), StickerIds = Optional.Convert(message.StickerIds, stickerIds => stickerIds.ToArray()), Flags = message.Flags, + Poll = Optional.Convert(message.Poll, poll => poll.ToModel()), EnforceNonce = message.ShouldEnforceNonce }; diff --git a/src/Disqord.Rest/Extensions/RestClientExtensions.Poll.cs b/src/Disqord.Rest/Extensions/RestClientExtensions.Poll.cs new file mode 100644 index 000000000..0c555bf0e --- /dev/null +++ b/src/Disqord.Rest/Extensions/RestClientExtensions.Poll.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Disqord.Rest.Api; +using Disqord.Rest.Pagination; +using Qommon; +using Qommon.Collections.ReadOnly; + +namespace Disqord.Rest; + +public static partial class RestClientExtensions +{ + public static IPagedEnumerable EnumeratePollAnswerVoters(this IRestClient client, + Snowflake channelId, Snowflake messageId, int answerId, int limit, Snowflake? startFromId = null, + IRestRequestOptions? options = null) + { + Guard.IsGreaterThanOrEqualTo(limit, 0); + + return PagedEnumerable.Create((state, cancellationToken) => + { + var (client, channelId, messageId, answerId, limit, startFromId, options) = state; + return new FetchPollAnswerVotersPagedEnumerator(client, channelId, messageId, answerId, limit, startFromId, options, cancellationToken); + }, (client, channelId, messageId, answerId, limit, startFromId, options)); + } + + public static Task> FetchPollAnswerVotersAsync(this IRestClient client, + Snowflake channelId, Snowflake messageId, int answerId, int limit = Discord.Limits.Rest.FetchPollAnswerVotersPageSize, Snowflake? startFromId = null, + IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + if (limit == 0) + return Task.FromResult(ReadOnlyList.Empty); + + if (limit <= Discord.Limits.Rest.FetchPollAnswerVotersPageSize) + return client.InternalFetchPollAnswerVotersAsync(channelId, messageId, answerId, limit, startFromId, options, cancellationToken); + + var enumerable = client.EnumeratePollAnswerVoters(channelId, messageId, answerId, limit, startFromId, options); + return enumerable.FlattenAsync(cancellationToken); + } + + internal static async Task> InternalFetchPollAnswerVotersAsync(this IRestClient client, + Snowflake channelId, Snowflake messageId, int answerId, int limit, Snowflake? startFromId, + IRestRequestOptions? options, CancellationToken cancellationToken) + { + var models = await client.ApiClient.FetchAnswerVotersAsync(channelId, messageId, answerId, limit, startFromId, options, cancellationToken).ConfigureAwait(false); + return models.ToReadOnlyList(client, (x, client) => new TransientUser(client, x)); + } + + public static Task EndPollAsync(this IRestClient client, + Snowflake channelId, Snowflake messageId, + IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + return client.ApiClient.EndPollAsync(channelId, messageId, options, cancellationToken); + } +} diff --git a/src/Disqord.Rest/Requests/Pagination/Implementation/FetchPollAnswerVotersPagedEnumerator.cs b/src/Disqord.Rest/Requests/Pagination/Implementation/FetchPollAnswerVotersPagedEnumerator.cs new file mode 100644 index 000000000..b08589d0f --- /dev/null +++ b/src/Disqord.Rest/Requests/Pagination/Implementation/FetchPollAnswerVotersPagedEnumerator.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Disqord.Rest; + +public class FetchPollAnswerVotersPagedEnumerator : PagedEnumerator +{ + public override int PageSize => Discord.Limits.Rest.FetchPollAnswerVotersPageSize; + + private readonly Snowflake _channelId; + private readonly Snowflake _messageId; + private readonly int _answerId; + private readonly Snowflake? _startFromId; + + public FetchPollAnswerVotersPagedEnumerator( + IRestClient client, + Snowflake channelId, Snowflake messageId, int answerId, int limit, Snowflake? startFromId, + IRestRequestOptions? options, + CancellationToken cancellationToken) + : base(client, limit, options, cancellationToken) + { + _channelId = channelId; + _messageId = messageId; + _answerId = answerId; + _startFromId = startFromId; + } + + protected override Task> NextPageAsync( + IReadOnlyList? previousPage, IRestRequestOptions? options = null, CancellationToken cancellationToken = default) + { + var startFromId = _startFromId; + if (previousPage != null && previousPage.Count > 0) + startFromId = previousPage[^1].Id; + + return Client.InternalFetchPollAnswerVotersAsync(_channelId, _messageId, _answerId, NextPageSize, startFromId, options, cancellationToken); + } +} diff --git a/src/Disqord/Hosting/Services/DiscordClientService.Callbacks.cs b/src/Disqord/Hosting/Services/DiscordClientService.Callbacks.cs index 09be47d0c..bacc4e96d 100644 --- a/src/Disqord/Hosting/Services/DiscordClientService.Callbacks.cs +++ b/src/Disqord/Hosting/Services/DiscordClientService.Callbacks.cs @@ -221,6 +221,14 @@ protected internal virtual ValueTask OnStageUpdated(StageUpdatedEventArgs e) protected internal virtual ValueTask OnStageDeleted(StageDeletedEventArgs e) => default; + /// + protected internal virtual ValueTask OnPollVoteAdded(PollVoteAddedEventArgs e) + => default; + + /// + protected internal virtual ValueTask OnPollVoteRemoved(PollVoteRemovedEventArgs e) + => default; + /// protected internal virtual ValueTask OnTypingStarted(TypingStartedEventArgs e) => default; diff --git a/src/Disqord/Hosting/Services/Master/DiscordClientMasterService.Hooks.cs b/src/Disqord/Hosting/Services/Master/DiscordClientMasterService.Hooks.cs index 108125802..6d7f4acf6 100644 --- a/src/Disqord/Hosting/Services/Master/DiscordClientMasterService.Hooks.cs +++ b/src/Disqord/Hosting/Services/Master/DiscordClientMasterService.Hooks.cs @@ -117,6 +117,10 @@ public partial class DiscordClientMasterService public DiscordClientService[] StageDeletedServices { get; } + public DiscordClientService[] PollVoteAddedServices { get; } + + public DiscordClientService[] PollVoteRemovedServices { get; } + public DiscordClientService[] TypingStartedServices { get; } public DiscordClientService[] CurrentUserUpdatedServices { get; } @@ -194,6 +198,8 @@ private DiscordClientMasterService( StageCreatedServices = GetServices(servicesArray, nameof(DiscordClientService.OnStageCreated)); StageUpdatedServices = GetServices(servicesArray, nameof(DiscordClientService.OnStageUpdated)); StageDeletedServices = GetServices(servicesArray, nameof(DiscordClientService.OnStageDeleted)); + PollVoteAddedServices = GetServices(servicesArray, nameof(DiscordClientService.OnPollVoteAdded)); + PollVoteRemovedServices = GetServices(servicesArray, nameof(DiscordClientService.OnPollVoteRemoved)); PresenceUpdatedServices = GetServices(servicesArray, nameof(DiscordClientService.OnPresenceUpdated)); TypingStartedServices = GetServices(servicesArray, nameof(DiscordClientService.OnTypingStarted)); CurrentUserUpdatedServices = GetServices(servicesArray, nameof(DiscordClientService.OnCurrentUserUpdated)); @@ -255,6 +261,8 @@ private DiscordClientMasterService( Client.StageCreated += HandleStageCreated; Client.StageUpdated += HandleStageUpdated; Client.StageDeleted += HandleStageDeleted; + Client.PollVoteAdded += HandlePollVoteAdded; + Client.PollVoteRemoved += HandlePollVoteRemoved; Client.TypingStarted += HandleTypingStarted; Client.CurrentUserUpdated += HandleCurrentUserUpdated; Client.VoiceStateUpdated += HandleVoiceStateUpdated; @@ -586,6 +594,18 @@ public async Task HandleStageDeleted(object? sender, StageDeletedEventArgs e) await ExecuteAsync((service, e) => service.OnStageDeleted(e), service, e).ConfigureAwait(false); } + public async Task HandlePollVoteAdded(object? sender, PollVoteAddedEventArgs e) + { + foreach (var service in PollVoteAddedServices) + await ExecuteAsync((service, e) => service.OnPollVoteAdded(e), service, e).ConfigureAwait(false); + } + + public async Task HandlePollVoteRemoved(object? sender, PollVoteRemovedEventArgs e) + { + foreach (var service in PollVoteRemovedServices) + await ExecuteAsync((service, e) => service.OnPollVoteRemoved(e), service, e).ConfigureAwait(false); + } + public async Task HandleTypingStarted(object? sender, TypingStartedEventArgs e) { foreach (var service in TypingStartedServices)