diff --git a/src/HonzaBotner.Discord.Services/Commands/PollCommands.cs b/src/HonzaBotner.Discord.Services/Commands/PollCommands.cs index 2faf5838..a6f15ac4 100644 --- a/src/HonzaBotner.Discord.Services/Commands/PollCommands.cs +++ b/src/HonzaBotner.Discord.Services/Commands/PollCommands.cs @@ -22,11 +22,14 @@ public class PollCommands : BaseCommandModule private readonly CommonCommandOptions _options; private readonly ILogger _logger; + private readonly IGuildProvider _guildProvider; - public PollCommands(IOptions options, ILogger logger) + public PollCommands(IOptions options, ILogger logger, + IGuildProvider guildProvider) { _options = options.Value; _logger = logger; + _guildProvider = guildProvider; } [GroupCommand] @@ -83,27 +86,27 @@ public async Task AbcPollCommandAsync( private async Task CreateDefaultPollAsync(CommandContext ctx, string question, List? answers = null) { - Poll poll = answers is null - ? new YesNoPoll(ctx.Member.Mention, question) - : new AbcPoll(ctx.Member.Mention, question, answers); - try { + Poll poll = answers is null + ? new YesNoPoll(ctx.Member.Mention, question) + : new AbcPoll(ctx.Member.Mention, question, answers); + await poll.PostAsync(ctx.Client, ctx.Channel); await ctx.Message.DeleteAsync(); } - catch (ArgumentException e) + catch (PollException e) { await ctx.RespondAsync(e.Message); } catch (Exception e) { await ctx.RespondAsync(PollErrorMessage); - _logger.LogWarning(e, "Failed to create new {PollType}", poll.PollType); + _logger.LogWarning(e, "Failed to create new Poll"); } } - private async Task PollHelpAsync(CommandContext ctx) + private static async Task PollHelpAsync(CommandContext ctx) { DiscordEmbed embed = new DiscordEmbedBuilder() .WithTitle("Polls") @@ -146,21 +149,23 @@ public async Task AddPollOptionAsync( return; } - DiscordRole modRole = (await ctx.Client.GetGuildAsync(ctx.Guild.Id)).GetRole(_options.ModRoleId); - AbcPoll poll = new(originalMessage); - - if (poll.AuthorMention != ctx.Member.Mention && !ctx.Member.Roles.Contains(modRole)) - { - await ctx.RespondAsync("You are not authorized to edit this poll"); - return; - } - try { - await new AbcPoll(originalMessage).AddOptionsAsync(ctx.Client, options.ToList()); + DiscordRole modRole = (await _guildProvider.GetCurrentGuildAsync()).GetRole(_options.ModRoleId); + + // I am sorry, due to DSharpPlus' caching logic, this mess is necessary + AbcPoll poll = new (await (await ctx.Client.GetChannelAsync(ctx.Channel.Id)).GetMessageAsync(ctx.Message.ReferencedMessage.Id)); + + if (poll.AuthorMention != ctx.Member?.Mention && !(ctx.Member?.Roles.Contains(modRole) ?? false)) + { + await ctx.RespondAsync("You are not authorized to edit this poll"); + return; + } + + await poll.AddOptionsAsync(ctx.Client, options); await ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, ":+1:")); } - catch (ArgumentException e) + catch (PollException e) { await ctx.RespondAsync(e.Message); } diff --git a/src/HonzaBotner.Discord.Services/Commands/Polls/AbcPoll.cs b/src/HonzaBotner.Discord.Services/Commands/Polls/AbcPoll.cs index 46b6e132..a6cf6231 100644 --- a/src/HonzaBotner.Discord.Services/Commands/Polls/AbcPoll.cs +++ b/src/HonzaBotner.Discord.Services/Commands/Polls/AbcPoll.cs @@ -1,5 +1,10 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DSharpPlus; using DSharpPlus.Entities; +using HonzaBotner.Discord.Services.Extensions; namespace HonzaBotner.Discord.Services.Commands.Polls; @@ -7,7 +12,7 @@ public class AbcPoll : Poll { public override string PollType => "AbcPoll"; - public override List OptionsEmoji => new() + protected override List OptionsEmoji => new() { ":regional_indicator_a:", ":regional_indicator_b:", @@ -35,4 +40,59 @@ public AbcPoll(string authorMention, string question, List options) public AbcPoll(DiscordMessage message) : base(message) { } + + public async Task AddOptionsAsync(DiscordClient client, IEnumerable newOptions) + { + const int reactionCap = 20; // Max amount of reactions present on a message, lower than Discord provided, in case some trolls block bot's reactions + if (ExistingPollMessage is null) + { + throw new InvalidOperationException("You can edit only poll constructed from sent message."); + } + + NewChoices = newOptions.ToList(); + + List emojisToAdd = OptionsEmoji; + + // Look at existing message and allow only emojis which are not yet present on that message. + emojisToAdd.RemoveAll(emoji => + ExistingPollMessage.Reactions.Select(rect => rect.Emoji).Contains(DiscordEmoji.FromName(client, emoji))); + + emojisToAdd = emojisToAdd.GetRange( + 0, + // Allow only so many reactions, that we don't cross 20 reactions on existing message + Math.Min(Math.Min(reactionCap - Math.Min(ExistingPollMessage.Reactions.Count, reactionCap), + // Take above reaction capacity, and lower it optionally to number of emojis which we are able to react with + emojisToAdd.Count), + // Take the above number and cap it at total new choices we want to add (can be lower or equal to real choices number) + NewChoices.Count)); + + // The new options count will be equal or lower than total options added, based on available emojis + NewChoices = NewChoices.GetRange(0, emojisToAdd.Count); + + if (NewChoices.Count == 0) + { + throw new PollException($"Total number of reactions on a message can't be greater than {reactionCap}"); + } + await ExistingPollMessage + .ModifyAsync(Modify(client, ExistingPollMessage.Channel.Guild, ExistingPollMessage.Embeds[0], emojisToAdd)); + + Task _ = Task.Run(async () => { await AddReactionsAsync(client, ExistingPollMessage, emojisToAdd); }); + } + + private DiscordEmbed Modify(DiscordClient client, DiscordGuild guild, DiscordEmbed original, IEnumerable emojisToAdd) + { + DiscordEmbedBuilder builder = new (original); + + NewChoices.Zip(emojisToAdd).ToList().ForEach(pair => + { + (string? answer, string? emojiName) = pair; + + builder.AddField( + DiscordEmoji.FromName(client, emojiName).ToString(), + answer.RemoveDiscordMentions(guild), + true); + }); + + return builder.WithFooter(PollType).Build(); + } } diff --git a/src/HonzaBotner.Discord.Services/Commands/Polls/Poll.cs b/src/HonzaBotner.Discord.Services/Commands/Polls/Poll.cs index d5a7e6e0..28403812 100644 --- a/src/HonzaBotner.Discord.Services/Commands/Polls/Poll.cs +++ b/src/HonzaBotner.Discord.Services/Commands/Polls/Poll.cs @@ -10,41 +10,37 @@ namespace HonzaBotner.Discord.Services.Commands.Polls; public abstract class Poll { - public abstract List OptionsEmoji { get; } + protected abstract List OptionsEmoji { get; } public abstract string PollType { get; } - - public virtual List ActiveEmojis + protected virtual List UsedEmojis { - get => OptionsEmoji.GetRange(0, _choices.Count); + get => OptionsEmoji.GetRange(0, NewChoices.Count); } + protected List NewChoices; + public readonly string AuthorMention; - private readonly List _choices; - private readonly DiscordMessage? _existingPollMessage; - private readonly string _question; + protected readonly DiscordMessage? ExistingPollMessage; + protected readonly string Question; protected Poll(string authorMention, string question, List? options = null) { AuthorMention = authorMention; - _question = question; - _choices = options ?? new List(); + Question = question; + NewChoices = options ?? new List(); } protected Poll(DiscordMessage originalMessage) { - _existingPollMessage = originalMessage; - DiscordEmbed originalPoll = _existingPollMessage.Embeds[0]; + ExistingPollMessage = originalMessage; + DiscordEmbed originalPoll = ExistingPollMessage.Embeds[0]; // Extract original author Mention via discord's mention format <@!123456789>. AuthorMention = originalPoll.Description.Substring( originalPoll.Description.LastIndexOf("<", StringComparison.Ordinal) ); - _choices = originalPoll.Fields? - .Select(ef => ef.Value) - .ToList() ?? new List(); - - _question = originalPoll.Title; + Question = originalPoll.Title; } public async Task PostAsync(DiscordClient client, DiscordChannel channel) @@ -54,23 +50,9 @@ public async Task PostAsync(DiscordClient client, DiscordChannel channel) Task _ = Task.Run(async () => { await AddReactionsAsync(client, pollMessage); }); } - public virtual async Task AddOptionsAsync(DiscordClient client, IEnumerable newOptions) - { - if (_existingPollMessage == null) - { - throw new InvalidOperationException("You can edit only poll constructed from sent message."); - } - - _choices.AddRange(newOptions); - - await _existingPollMessage.ModifyAsync(Build(client, _existingPollMessage.Channel.Guild)); - - Task _ = Task.Run(async () => { await AddReactionsAsync(client, _existingPollMessage); }); - } - - protected async Task AddReactionsAsync(DiscordClient client, DiscordMessage message) + protected async Task AddReactionsAsync(DiscordClient client, DiscordMessage message, List? reactions = null) { - foreach (string reaction in ActiveEmojis) + foreach (string reaction in reactions ?? UsedEmojis) { await message.CreateReactionAsync(DiscordEmoji.FromName(client, reaction)); } @@ -78,18 +60,18 @@ protected async Task AddReactionsAsync(DiscordClient client, DiscordMessage mess private DiscordEmbed Build(DiscordClient client, DiscordGuild guild) { - if (_choices.Count > OptionsEmoji.Count) + if (NewChoices.Count > OptionsEmoji.Count) { - throw new ArgumentException($"Too many options. Maximum options is {OptionsEmoji.Count}."); + throw new PollException($"Too many options. Maximum options is {OptionsEmoji.Count}."); } DiscordEmbedBuilder builder = new() { - Title = _question.RemoveDiscordMentions(guild), + Title = Question.RemoveDiscordMentions(guild), Description = "By: " + AuthorMention // Author needs to stay as the last argument }; - _choices.Zip(ActiveEmojis).ToList().ForEach(pair => + NewChoices.Zip(UsedEmojis).ToList().ForEach(pair => { (string? answer, string? emojiName) = pair; diff --git a/src/HonzaBotner.Discord.Services/Commands/Polls/PollException.cs b/src/HonzaBotner.Discord.Services/Commands/Polls/PollException.cs new file mode 100644 index 00000000..835256cb --- /dev/null +++ b/src/HonzaBotner.Discord.Services/Commands/Polls/PollException.cs @@ -0,0 +1,17 @@ +using System; + +namespace HonzaBotner.Discord.Services.Commands.Polls; + +public class PollException : Exception +{ + public PollException () + {} + + public PollException (string message) + : base(message) + {} + + public PollException (string message, Exception innerException) + : base (message, innerException) + {} +} diff --git a/src/HonzaBotner.Discord.Services/Commands/Polls/YesNoPoll.cs b/src/HonzaBotner.Discord.Services/Commands/Polls/YesNoPoll.cs index 42a36b47..c2d4e902 100644 --- a/src/HonzaBotner.Discord.Services/Commands/Polls/YesNoPoll.cs +++ b/src/HonzaBotner.Discord.Services/Commands/Polls/YesNoPoll.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using DSharpPlus; +using System.Collections.Generic; using DSharpPlus.Entities; namespace HonzaBotner.Discord.Services.Commands.Polls; @@ -9,8 +6,12 @@ namespace HonzaBotner.Discord.Services.Commands.Polls; public class YesNoPoll : Poll { public override string PollType => "YesNoPoll"; - public override List OptionsEmoji => new() { ":+1:", ":-1:" }; - public override List ActiveEmojis => OptionsEmoji; + protected override List OptionsEmoji => new() { ":+1:", ":-1:" }; + + protected override List UsedEmojis + { + get => OptionsEmoji; + } public YesNoPoll(string authorMention, string question) : base(authorMention, question) { @@ -19,7 +20,4 @@ public YesNoPoll(string authorMention, string question) : base(authorMention, qu public YesNoPoll(DiscordMessage message) : base(message) { } - - public override Task AddOptionsAsync(DiscordClient client, IEnumerable newOptions) => - throw new ArgumentException($"Adding options is disabled for {PollType}"); } diff --git a/src/HonzaBotner.Discord.Services/EventHandlers/PollReactionsHandler.cs b/src/HonzaBotner.Discord.Services/EventHandlers/PollReactionsHandler.cs deleted file mode 100644 index 27f141d3..00000000 --- a/src/HonzaBotner.Discord.Services/EventHandlers/PollReactionsHandler.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; -using HonzaBotner.Discord.EventHandler; -using Microsoft.Extensions.Logging; - -namespace HonzaBotner.Discord.Services.EventHandlers; - -public class PollReactionsHandler : IEventHandler -{ - - private readonly ILogger _logger; - - public PollReactionsHandler(ILogger logger) - { - _logger = logger; - } - - public Task Handle(MessageReactionAddEventArgs args) - { - if (args.User.IsBot) return Task.FromResult(EventHandlerResult.Continue); - _ = Task.Run(() => HandleAsync(args)); - return Task.FromResult(EventHandlerResult.Continue); - } - - private async Task HandleAsync(MessageReactionAddEventArgs args) - { - DiscordMessage message; - try - { - message = await args.Channel.GetMessageAsync(args.Message.Id); - } - catch (Exception e) - { - _logger.LogWarning(e, - "Failed while fetching message {MessageId} in channel {ChannelId} to check poll reactions", - args.Message.Id, args.Channel.Id - ); - return; - } - - if (!message.Author.IsCurrent - || (message.Embeds?.Count.Equals(0) ?? true) - || !(message.Embeds[0].Footer?.Text.EndsWith("Poll") ?? false)) - { - return; - } - - if (message.Reactions.FirstOrDefault(x => x.Emoji == args.Emoji)?.IsMe ?? false) return; - - await args.Message.DeleteReactionAsync(args.Emoji, args.User); - } -} diff --git a/src/HonzaBotner/Startup.cs b/src/HonzaBotner/Startup.cs index af093e84..fe7867ec 100644 --- a/src/HonzaBotner/Startup.cs +++ b/src/HonzaBotner/Startup.cs @@ -79,7 +79,6 @@ public void ConfigureServices(IServiceCollection services) .AddEventHandler() .AddEventHandler() .AddEventHandler() - .AddEventHandler(EventHandlerPriority.Low) .AddEventHandler(EventHandlerPriority.High) .AddEventHandler(EventHandlerPriority.Urgent) .AddEventHandler(EventHandlerPriority.Urgent)