From 8ba9448beb838959e0008a040d752b938f3f3a42 Mon Sep 17 00:00:00 2001 From: Andrei Tudor Date: Thu, 22 May 2025 20:18:50 +0300 Subject: [PATCH] Created Poll Maker plugin --- Plugins/PollMaker/Internal/PollState.cs | 32 ++++ Plugins/PollMaker/PollMaker.csproj | 17 ++ Plugins/PollMaker/SlashCommands/MakePoll.cs | 171 ++++++++++++++++++++ SethDiscordBot.sln | 15 ++ 4 files changed, 235 insertions(+) create mode 100644 Plugins/PollMaker/Internal/PollState.cs create mode 100644 Plugins/PollMaker/PollMaker.csproj create mode 100644 Plugins/PollMaker/SlashCommands/MakePoll.cs diff --git a/Plugins/PollMaker/Internal/PollState.cs b/Plugins/PollMaker/Internal/PollState.cs new file mode 100644 index 0000000..5cacce7 --- /dev/null +++ b/Plugins/PollMaker/Internal/PollState.cs @@ -0,0 +1,32 @@ +namespace PollMaker.Internal; + +internal sealed record PollState(string Question, string[] Options) +{ + public List> Votes { get; } = + Enumerable.Range(0, Options.Length).Select(_ => new HashSet()).ToList(); + + public bool IsOpen { get; private set; } = true; + + public void Close() => IsOpen = false; + + /// + /// Toggle the member’s vote. + /// Clicking the **same button** again removes their vote; + /// clicking a **different** button moves the vote. + /// + public void ToggleVote(int optionIdx, ulong userId) + { + if (!IsOpen) return; + + if (Votes[optionIdx].Contains(userId)) + { + Votes[optionIdx].Remove(userId); + return; + } + + foreach (var set in Votes) + set.Remove(userId); + + Votes[optionIdx].Add(userId); + } +} \ No newline at end of file diff --git a/Plugins/PollMaker/PollMaker.csproj b/Plugins/PollMaker/PollMaker.csproj new file mode 100644 index 0000000..927d7d5 --- /dev/null +++ b/Plugins/PollMaker/PollMaker.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Plugins/PollMaker/SlashCommands/MakePoll.cs b/Plugins/PollMaker/SlashCommands/MakePoll.cs new file mode 100644 index 0000000..4ffa416 --- /dev/null +++ b/Plugins/PollMaker/SlashCommands/MakePoll.cs @@ -0,0 +1,171 @@ +using System.Collections.Concurrent; +using Discord; +using Discord.WebSocket; +using DiscordBotCore.Logging; +using DiscordBotCore.PluginCore.Interfaces; +using PollMaker.Internal; + +namespace PollMaker.SlashCommands; + +public class MakePoll : IDbSlashCommand +{ + public string Name => "make-poll"; + public string Description => "Create an interactive poll (2-25 answers, optional timer)"; + public bool CanUseDm => false; + public bool HasInteraction => true; + + // ─────────── slash-command schema ─────────── + public List Options => + [ + new SlashCommandOptionBuilder + { + Name = "question", + Description = "The poll question", + Type = ApplicationCommandOptionType.String, + IsRequired = true + }, + new SlashCommandOptionBuilder + { + Name = "answers", + Description = "Answers separated with ';' (min 2, max 25)", + Type = ApplicationCommandOptionType.String, + IsRequired = true + }, + new SlashCommandOptionBuilder + { + Name = "timed", + Description = "Close the poll automatically after a given duration", + Type = ApplicationCommandOptionType.Boolean, + IsRequired = false + }, + new SlashCommandOptionBuilder + { + Name = "duration", + Description = "Duration in **hours** (1-168) – required if timed = true", + Type = ApplicationCommandOptionType.Integer, + MinValue = 1, + MaxValue = 168, + IsRequired = false + } + ]; + + // ─────────── in-memory cache ─────────── + private static readonly ConcurrentDictionary Polls = new(); + + // ─────────── slash-command handler ─────────── + public async void ExecuteServer(ILogger log, SocketSlashCommand ctx) + { + string q = ctx.Data.Options.First(o => o.Name == "question").Value!.ToString()!.Trim(); + string raw = ctx.Data.Options.First(o => o.Name == "answers" ).Value!.ToString()!; + + bool timed = ctx.Data.Options.FirstOrDefault(o => o.Name == "timed")?.Value is bool b && b; + int hours = ctx.Data.Options.FirstOrDefault(o => o.Name == "duration")?.Value is long l ? (int)l : 0; + + if (timed && hours == 0) + { + await ctx.RespondAsync("❗ When `timed` is **true**, you must supply a `duration` (1-168 hours).", + ephemeral: true); + return; + } + + var opts = raw.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(a => a.Trim()) + .Where(a => a.Length > 0) + .Distinct() + .ToArray(); + + if (opts.Length < 2 || opts.Length > 25) + { + await ctx.RespondAsync($"❗ You must supply **2-25** answers; you supplied {opts.Length}.", + ephemeral: true); + return; + } + + var embed = new EmbedBuilder() + .WithTitle($"📊 {q}") + .WithDescription(string.Join('\n', opts.Select((o,i) => $"{i+1}. {o}"))) + .WithColor(Color.Purple) + .WithFooter(timed + ? $"Click a button to vote • click again to un-vote • closes in {hours} h" + : "Click a button to vote • click again to un-vote") + .Build(); + + var cb = new ComponentBuilder(); + for (int i = 0; i < opts.Length; i++) + cb.WithButton(label: $"{i+1}", + customId: $"poll:{ctx.Id}:{i}", // poll:{slashId}:{idx} + style: ButtonStyle.Secondary, + row: i / 5); + + await ctx.RespondAsync(embed: embed, components: cb.Build()); + var msg = await ctx.GetOriginalResponseAsync(); + + var state = new PollState(q, opts); + Polls[msg.Id] = state; + + if (timed) + _ = ClosePollLaterAsync(log, msg, state, hours); + } + + public async Task ExecuteInteraction(ILogger log, SocketInteraction interaction) + { + if (interaction is not SocketMessageComponent btn || !btn.Data.CustomId.StartsWith("poll:")) + return; + + if (!Polls.TryGetValue(btn.Message.Id, out var poll)) + { + await btn.RespondAsync("This poll is no longer active.", ephemeral: true); + return; + } + + if (!poll.IsOpen) + { + await btn.RespondAsync("The poll has already closed.", ephemeral: true); + return; + } + + var optionIdx = int.Parse(btn.Data.CustomId.Split(':')[2]); + + poll.ToggleVote(optionIdx, btn.User.Id); + + var embed = new EmbedBuilder() + .WithTitle($"📊 {poll.Question}") + .WithDescription(string.Join('\n', + poll.Options.Select((o,i) => $"{i+1}. {o} — **{poll.Votes[i].Count}**"))) + .WithColor(Color.Purple) + .WithFooter("Click a button to vote • click again to un-vote") + .Build(); + + await btn.Message.ModifyAsync(m => m.Embed = embed); + await btn.DeferAsync(); + } + private static async Task ClosePollLaterAsync(ILogger log, IUserMessage msg, PollState poll, int hours) + { + try + { + await Task.Delay(TimeSpan.FromHours(hours)); + + poll.Close(); + + var closedEmbed = new EmbedBuilder() + .WithTitle($"📊 {poll.Question} — closed") + .WithDescription(string.Join('\n', + poll.Options.Select((o,i) => $"{i+1}. {o} — **{poll.Votes[i].Count}**"))) + .WithColor(Color.DarkGrey) + .WithFooter($"Poll closed after {hours} h • thanks for voting!") + .Build(); + + await msg.ModifyAsync(m => + { + m.Embed = closedEmbed; + m.Components = new ComponentBuilder().Build(); + }); + + Polls.TryRemove(msg.Id, out _); + } + catch (Exception ex) + { + log.LogException(ex, typeof(MakePoll)); + } + } +} diff --git a/SethDiscordBot.sln b/SethDiscordBot.sln index 5112025..ae45d6c 100644 --- a/SethDiscordBot.sln +++ b/SethDiscordBot.sln @@ -35,6 +35,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordBotCore.Database.Sql EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebUI", "WebUI\WebUI.csproj", "{DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PollMaker", "Plugins\PollMaker\PollMaker.csproj", "{9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -201,6 +203,18 @@ Global {DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}.Release|ARM64.Build.0 = Release|ARM64 {DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}.Release|x64.ActiveCfg = Release|x64 {DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}.Release|x64.Build.0 = Release|x64 + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Debug|ARM64.Build.0 = Debug|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Debug|x64.Build.0 = Debug|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Release|Any CPU.Build.0 = Release|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Release|ARM64.ActiveCfg = Release|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Release|ARM64.Build.0 = Release|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Release|x64.ActiveCfg = Release|Any CPU + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -209,6 +223,7 @@ Global {FCE9743F-7EB4-4639-A080-FCDDFCC7D689} = {5CF9AD7B-6BF0-4035-835F-722F989C01E1} {F3C61A47-F758-4145-B496-E3ECCF979D89} = {5CF9AD7B-6BF0-4035-835F-722F989C01E1} {C67908F9-4A55-4DD8-B993-C26C648226F1} = {EA4FA308-7B2C-458E-8485-8747D745DD59} + {9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7} = {5CF9AD7B-6BF0-4035-835F-722F989C01E1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3FB3C5DE-ED21-4D2E-ABDD-3A00EE4A2FFF}