Created Poll Maker plugin
This commit is contained in:
32
Plugins/PollMaker/Internal/PollState.cs
Normal file
32
Plugins/PollMaker/Internal/PollState.cs
Normal 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 member’s 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Plugins/PollMaker/PollMaker.csproj
Normal file
17
Plugins/PollMaker/PollMaker.csproj
Normal 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>
|
||||||
171
Plugins/PollMaker/SlashCommands/MakePoll.cs
Normal file
171
Plugins/PollMaker/SlashCommands/MakePoll.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordBotCore.Database.Sql
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebUI", "WebUI\WebUI.csproj", "{DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebUI", "WebUI\WebUI.csproj", "{DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PollMaker", "Plugins\PollMaker\PollMaker.csproj", "{9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|ARM64.Build.0 = Release|ARM64
|
||||||
{DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}.Release|x64.ActiveCfg = Release|x64
|
{DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}.Release|x64.ActiveCfg = Release|x64
|
||||||
{DE42253E-2ED6-4653-B9CC-C2C2551E1EA8}.Release|x64.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -209,6 +223,7 @@ Global
|
|||||||
{FCE9743F-7EB4-4639-A080-FCDDFCC7D689} = {5CF9AD7B-6BF0-4035-835F-722F989C01E1}
|
{FCE9743F-7EB4-4639-A080-FCDDFCC7D689} = {5CF9AD7B-6BF0-4035-835F-722F989C01E1}
|
||||||
{F3C61A47-F758-4145-B496-E3ECCF979D89} = {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}
|
{C67908F9-4A55-4DD8-B993-C26C648226F1} = {EA4FA308-7B2C-458E-8485-8747D745DD59}
|
||||||
|
{9A4B98C1-00AC-481C-BE55-A70C0B9D3BE7} = {5CF9AD7B-6BF0-4035-835F-722F989C01E1}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {3FB3C5DE-ED21-4D2E-ABDD-3A00EE4A2FFF}
|
SolutionGuid = {3FB3C5DE-ED21-4D2E-ABDD-3A00EE4A2FFF}
|
||||||
|
|||||||
Reference in New Issue
Block a user