Created Poll Maker plugin

This commit is contained in:
2025-05-22 20:18:50 +03:00
parent f70e8a565b
commit 8ba9448beb
4 changed files with 235 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
namespace PollMaker.Internal;
internal sealed record PollState(string Question, string[] Options)
{
public List<HashSet<ulong>> Votes { get; } =
Enumerable.Range(0, Options.Length).Select(_ => new HashSet<ulong>()).ToList();
public bool IsOpen { get; private set; } = true;
public void Close() => IsOpen = false;
/// <summary>
/// Toggle the members vote.
/// Clicking the **same button** again removes their vote;
/// clicking a **different** button moves the vote.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\DiscordBotCore.PluginCore\DiscordBotCore.PluginCore.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Commands\" />
</ItemGroup>
</Project>

View File

@@ -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<SlashCommandOptionBuilder> 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<ulong, PollState> 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));
}
}
}

View File

@@ -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}