From 2f4cc66d150ce16fceb3fd3be5ecadb88e01d9c2 Mon Sep 17 00:00:00 2001 From: zobweyt Date: Thu, 27 Jun 2024 17:55:57 +0300 Subject: [PATCH] Add initial functionality --- .../Common/Args/GiveawayStartArgs.cs | 30 ++++ src/Giveaways/Modules/GiveawayModule.cs | 113 +++++++++++++++ src/Giveaways/Program.cs | 7 + src/Giveaways/Services/GiveawayFormatter.cs | 108 +++++++++++++++ src/Giveaways/Services/GiveawayService.cs | 131 ++++++++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 src/Giveaways/Common/Args/GiveawayStartArgs.cs create mode 100644 src/Giveaways/Modules/GiveawayModule.cs create mode 100644 src/Giveaways/Services/GiveawayFormatter.cs create mode 100644 src/Giveaways/Services/GiveawayService.cs diff --git a/src/Giveaways/Common/Args/GiveawayStartArgs.cs b/src/Giveaways/Common/Args/GiveawayStartArgs.cs new file mode 100644 index 0000000..a6ea177 --- /dev/null +++ b/src/Giveaways/Common/Args/GiveawayStartArgs.cs @@ -0,0 +1,30 @@ +using System; +using Discord.Interactions; + +namespace Giveaways; + +[method: ComplexParameterCtor] +public class GiveawayStartArgs( + [MinLength(2)] + [MaxLength(128)] + [Summary(description: "The prize of the giveaway.")] + string prize, + + [MinValue(1)] + [MaxValue(25)] + [Summary(description: "The maximum number of winners for the giveaway.")] + int winners, + + [Choice("1 hour", "1h")] + [Choice("4 hours", "4h")] + [Choice("8 hours", "8h")] + [Choice("1 day", "1d")] + [Choice("3 days", "3d")] + [Choice("1 week", "7d")] + [Summary(description: "The duration of the giveaway.")] + TimeSpan duration) +{ + public string Prize => prize; + public int MaxWinners => winners; + public DateTime ExpiresAt => DateTime.Now + duration; +} diff --git a/src/Giveaways/Modules/GiveawayModule.cs b/src/Giveaways/Modules/GiveawayModule.cs new file mode 100644 index 0000000..55cfb37 --- /dev/null +++ b/src/Giveaways/Modules/GiveawayModule.cs @@ -0,0 +1,113 @@ +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using Giveaways.Data; +using Giveaways.Services; +using Hangfire.States; +using Microsoft.EntityFrameworkCore; + +namespace Giveaways.Modules; + +[RequireContext(ContextType.Guild)] +public class GiveawayModule : ModuleBase +{ + private readonly AppDbContext _db; + private readonly GiveawayFormatter _formatter; + private readonly GiveawayScheduler _scheduler; + private readonly GiveawayService _service; + + public GiveawayModule(AppDbContext db, GiveawayFormatter formatter, GiveawayScheduler scheduler, GiveawayService service) + { + _db = db; + _formatter = formatter; + _scheduler = scheduler; + _service = service; + } + + [DefaultMemberPermissions(GuildPermission.ManageEvents)] + [SlashCommand("start", "Starts a new giveaway in the current channel.")] + public async Task Start([ComplexParameter] GiveawayStartArgs args) + { + await DeferAsync(); + var response = await GetOriginalResponseAsync(); + + var giveaway = new Giveaway() + { + MessageId = response.Id, + ChannelId = Context.Channel.Id, + GuildId = Context.Guild.Id, + Prize = args.Prize, + MaxWinners = args.MaxWinners, + ExpiresAt = args.ExpiresAt, + }; + + await _db.AddAsync(giveaway); + await _db.SaveChangesAsync(); + + _scheduler.Schedule(giveaway.MessageId, giveaway.ExpiresAt); + + var props = _formatter.GetActiveMessageProperties(giveaway); + await FollowupAsync(embed: props.Embed.Value, components: props.Components.Value); + } + + [ComponentInteraction("join:*")] + public async Task Join(ulong messageId) + { + await DeferAsync(true); + + var giveaway = await _db.Giveaways + .Include(g => g.Participants) + .FirstOrDefaultAsync(g => g.MessageId == messageId && g.Status == GiveawayStatus.Active); + + if (giveaway == null) + return InteractionResult.FromError("There's no active giveaway associated with this message."); + + if (await _service.GetGiveawayMessage(giveaway) is not IUserMessage message) + return InteractionResult.FromError("Unknown message!"); + + await _service.AddOrRemoveParticipantAsync(giveaway, Context.User.Id); + + var modifiedProps = _formatter.GetActiveMessageProperties(giveaway); + await message.ModifyAsync(props => + { + props.Embed = modifiedProps.Embed; + props.Components = modifiedProps.Components; + }); + + return InteractionResult.FromSuccess(); + } + + [ComponentInteraction("info:*")] + public async Task Info(ulong messageId) + { + await DeferAsync(true); + + var giveaway = await _db.Giveaways + .Include(g => g.Participants) + .FirstOrDefaultAsync(g => g.MessageId == messageId && g.Status != GiveawayStatus.Ended); + + if (giveaway == null) + return InteractionResult.FromError("There's no active giveaway associated with this message."); + + var props = _formatter.GetInfoMessageProperties(giveaway); + await FollowupAsync(embed: props.Embed.Value, ephemeral: true); + + return InteractionResult.FromSuccess(); + } + + [DefaultMemberPermissions(GuildPermission.ManageEvents)] + [MessageCommand("End Giveaway")] + public async Task End(IMessage message) + { + await DeferAsync(true); + + if (await _db.Giveaways.AnyAsync(g => g.MessageId == message.Id && g.Status == GiveawayStatus.Ended)) + return InteractionResult.FromError("There's no active giveaway associated with this message."); + + await _service.ExpireAsync(message.Id); + _scheduler.ChangeState(message.Id, new SucceededState(0, 0, 0)); + + return InteractionResult.FromSuccess("This giveaway has just been ended! Winners have been notified via DMs."); + } +} diff --git a/src/Giveaways/Program.cs b/src/Giveaways/Program.cs index f3e2536..1f36b8e 100644 --- a/src/Giveaways/Program.cs +++ b/src/Giveaways/Program.cs @@ -7,6 +7,8 @@ using Giveaways; using Giveaways.Data; using Giveaways.Services; +using Hangfire; +using Hangfire.Storage.SQLite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +20,8 @@ builder.Services.AddNamedOptions(); +builder.Services.AddHangfire(options => options.UseSQLiteStorage()); +builder.Services.AddHangfireServer(); builder.Services.AddSqlite(builder.Configuration.GetConnectionString("Default")); builder.Services.AddDiscordHost((config, _) => @@ -49,6 +53,9 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); var host = builder.Build(); diff --git a/src/Giveaways/Services/GiveawayFormatter.cs b/src/Giveaways/Services/GiveawayFormatter.cs new file mode 100644 index 0000000..d58d3f7 --- /dev/null +++ b/src/Giveaways/Services/GiveawayFormatter.cs @@ -0,0 +1,108 @@ +using System.Linq; +using Discord; +using Discord.WebSocket; +using Giveaways.Data; + +namespace Giveaways.Services; + +public class GiveawayFormatter +{ + private readonly DiscordSocketClient _client; + + public GiveawayFormatter(DiscordSocketClient client) + { + _client = client; + } + + public MessageProperties GetActiveMessageProperties(Giveaway giveaway) + { + var relative = TimestampTag.FormatFromDateTime(giveaway.ExpiresAt, TimestampTagStyles.Relative); + var longDateTime = TimestampTag.FormatFromDateTime(giveaway.ExpiresAt, TimestampTagStyles.LongDateTime); + + var embed = new EmbedBuilder() + .WithAuthor($"x{giveaway.MaxWinners} Giveaway Prizes", Icons.Gift) + .WithTitle(giveaway.Prize) + .AddField("Winners Selection Date", $"{relative} ({longDateTime})") + .WithFooter($"{giveaway.Participants.Count} participants") + .WithColor(Colors.Fuchsia) + .Build(); + + var components = new ComponentBuilder() + .WithButton("Join", $"join:{giveaway.MessageId}", ButtonStyle.Primary, new Emoji("🪅")) + .WithButton("Learn more", $"info:{giveaway.MessageId}", ButtonStyle.Secondary, new Emoji("❓")) + .Build(); + + return new MessageProperties + { + Embed = embed, + Components = components + }; + } + + public MessageProperties GetEndedMessageProperties(Giveaway giveaway) + { + var ids = giveaway.Winners.Select(w => MentionUtils.MentionUser(w.UserId)); + var mentions = string.Join(", ", ids); + + var embed = new EmbedBuilder() + .WithAuthor($"x{giveaway.MaxWinners} Giveaway Prizes", Icons.Confetti) + .WithTitle(giveaway.Prize) + .AddField("Winners", string.IsNullOrEmpty(mentions) ? "None" : mentions) + .WithFooter($"{giveaway.Participants.Count} participants") + .WithColor(Colors.Secondary) + .WithCurrentTimestamp() + .Build(); + + return new MessageProperties() + { + Embed = embed, + Components = null + }; + } + + public MessageProperties GetInfoMessageProperties(Giveaway giveaway) + { + var guild = _client.GetGuild(giveaway.GuildId); + + var longDate = TimestampTag.FormatFromDateTime(giveaway.ExpiresAt, TimestampTagStyles.LongDate); + var longDateTime = TimestampTag.FormatFromDateTime(giveaway.ExpiresAt, TimestampTagStyles.LongDateTime); + + var embed = new EmbedBuilder() + .WithTitle("About this event") + .AddField("How it works?", $"On {longDate} the app is going to choose **{giveaway.MaxWinners}** random winners.") + .AddField("Who can join?", $"{guild.EveryoneRole.Mention} can push the join button before {longDateTime} in order to enter.") + .WithFooter($"The event organizers of {guild.Name} are responsible for awarding prizes.") + .WithColor(Colors.Primary) + .Build(); + + return new MessageProperties + { + Embed = embed + }; + } + + public MessageProperties GetCongratsMessageProperties(GiveawayParticipant winner) + { + var guild = _client.GetGuild(winner.Giveaway.GuildId); + + var embed = new EmbedBuilder() + .WithAuthor($"Congrats!", Icons.Confetti) + .WithTitle($"You won the {winner.Giveaway.Prize}!") + .WithDescription($"To get your prize, visit **{guild.Name}** and contact their event organizers.") + .WithFooter("The app is not responsible for awarding prizes.") + .WithColor(Colors.Fuchsia) + .Build(); + + var url = MessageUtils.FormatJumpUrl(winner.Giveaway.GuildId, winner.Giveaway.ChannelId, winner.Giveaway.MessageId); + + var components = new ComponentBuilder() + .WithLink("Jump to Message", url, new Emoji("🎁")) + .Build(); + + return new MessageProperties + { + Embed = embed, + Components = components + }; + } +} diff --git a/src/Giveaways/Services/GiveawayService.cs b/src/Giveaways/Services/GiveawayService.cs new file mode 100644 index 0000000..fa0546b --- /dev/null +++ b/src/Giveaways/Services/GiveawayService.cs @@ -0,0 +1,131 @@ +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Net; +using Discord.WebSocket; +using Giveaways.Data; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Giveaways.Services; + +public class GiveawayService +{ + private readonly DiscordSocketClient _client; + private readonly ILogger _logger; + private readonly AppDbContext _db; + private readonly GiveawayFormatter _formatter; + + public GiveawayService(DiscordSocketClient client, ILogger logger, AppDbContext db, GiveawayFormatter formatter) + { + _client = client; + _logger = logger; + _db = db; + _formatter = formatter; + } + + /// + /// Adds or removes a participant from the . + /// + /// The giveaway object to add or remove the participant from. + /// The ID of the user to add or remove as a participant. + /// A task representing the asynchronous operation. + public async Task AddOrRemoveParticipantAsync(Giveaway giveaway, ulong userId) + { + var participant = giveaway.Participants.FirstOrDefault(p => p.UserId == userId); + + if (participant == null) + giveaway.Participants.Add(new() { UserId = userId }); + else + _db.Remove(participant); + + await _db.SaveChangesAsync(); + } + + /// + /// Immediately ends the giveaway associated with the . + /// + /// The message ID associated with a giveaway. + /// A task representing the asynchronous operation. + [DisableConcurrentExecution(60)] + public async Task ExpireAsync(ulong messageId) + { + var giveaway = await _db.Giveaways + .Include(g => g.Participants) + .FirstOrDefaultAsync(g => g.MessageId == messageId && g.Status != GiveawayStatus.Ended); + + if (giveaway == null) + return; + + await ExpireAsync(giveaway); + } + + /// + /// Immediately ends the . + /// + /// The giveaway to end immediately. + /// A task representing the asynchronous operation. + [DisableConcurrentExecution(60)] + public async Task ExpireAsync(Giveaway giveaway) + { + if (await GetGiveawayMessage(giveaway) is not IUserMessage message) + return; + + await SummarizeAsync(giveaway, message); + } + + /// + /// Gets the message associated with the . + /// + /// The giveaway for which to retrieve the associated message. + /// The message associated with the giveaway, or null if not found. + public async Task GetGiveawayMessage(Giveaway giveaway) + { + if (_client.GetChannel(giveaway.ChannelId) is ITextChannel channel) + return await channel.GetMessageAsync(giveaway.MessageId); + return null; + } + + private async Task SummarizeAsync(Giveaway giveaway, IUserMessage message) + { + giveaway.Status = GiveawayStatus.Ended; + await SetRandomWinners(giveaway); + + var modifiedProps = _formatter.GetEndedMessageProperties(giveaway); + await message.ModifyAsync(props => + { + props.Embed = modifiedProps.Embed; + props.Components = modifiedProps.Components; + }); + + await _db.SaveChangesAsync(); + } + + private async Task SetRandomWinners(Giveaway giveaway) + { + var participants = giveaway.Participants.Shuffle().Take(giveaway.MaxWinners); + + foreach (var participant in participants) + { + await CongratulateWinner(participant); + + participant.IsWinner = true; + } + } + + private async Task CongratulateWinner(GiveawayParticipant winner) + { + var user = await _client.GetUserAsync(winner.UserId); + var props = _formatter.GetCongratsMessageProperties(winner); + + try + { + await user.SendMessageAsync(embed: props.Embed.Value, components: props.Components.Value); + } + catch (HttpException) + { + _logger.LogDebug("Cannot send message to {username}.", user.Username); + } + } +}