using LinqToDB; using LinqToDB.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using EllieBot.Common.ModuleBehaviors; using EllieBot.Db.Models; namespace EllieBot.Modules.Utility; public sealed class GiveawayService : IEService, IReadyExecutor { public static string GiveawayEmoji = "🎉"; private readonly DbService _db; private readonly IBotCreds _creds; private readonly DiscordSocketClient _client; private readonly IMessageSenderService _sender; private readonly IBotStrings _strings; private readonly ILocalization _localization; private readonly IMemoryCache _cache; private SortedSet<GiveawayModel> _giveawayCache = new SortedSet<GiveawayModel>(); private readonly EllieRandom _rng; public GiveawayService( DbService db, IBotCreds creds, DiscordSocketClient client, IMessageSenderService sender, IBotStrings strings, ILocalization localization, IMemoryCache cache) { _db = db; _creds = creds; _client = client; _sender = sender; _strings = strings; _localization = localization; _cache = cache; _rng = new EllieRandom(); _client.ReactionAdded += OnReactionAdded; _client.ReactionRemoved += OnReactionRemoved; } private async Task OnReactionRemoved( Cacheable<IUserMessage, ulong> msg, Cacheable<IMessageChannel, ulong> arg2, SocketReaction r) { if (!r.User.IsSpecified) return; var user = r.User.Value; if (user.IsBot || user.IsWebhook) return; if (r.Emote is Emoji e && e.Name == GiveawayEmoji) { await LeaveGivawayAsync(msg.Id, user.Id); } } private async Task OnReactionAdded( Cacheable<IUserMessage, ulong> msg, Cacheable<IMessageChannel, ulong> ch, SocketReaction r) { if (!r.User.IsSpecified) return; var user = r.User.Value; if (user.IsBot || user.IsWebhook) return; var textChannel = ch.Value as ITextChannel; if (textChannel is null) return; if (r.Emote is Emoji e && e.Name == GiveawayEmoji) { await JoinGivawayAsync(msg.Id, user.Id, user.Username); } } public async Task OnReadyAsync() { // load giveaways for this shard from the database await using var ctx = _db.GetDbContext(); var gas = await ctx .GetTable<GiveawayModel>() .Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId)) .ToArrayAsync(); lock (_giveawayCache) { _giveawayCache = new(gas, Comparer<GiveawayModel>.Create((x, y) => x.EndsAt.CompareTo(y.EndsAt))); } var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); while (await timer.WaitForNextTickAsync()) { IEnumerable<GiveawayModel> toEnd; lock (_giveawayCache) { toEnd = _giveawayCache.TakeWhile( x => x.EndsAt <= DateTime.UtcNow.AddSeconds(15)) .ToArray(); } foreach (var ga in toEnd) { try { await EndGiveawayAsync(ga.GuildId, ga.Id); } catch { Log.Warning("Failed to end the giveaway with id {Id}", ga.Id); } } } } public async Task<int?> StartGiveawayAsync( ulong guildId, ulong channelId, ulong messageId, TimeSpan duration, string message) { await using var ctx = _db.GetDbContext(); // first check if there are more than 5 giveaways var count = await ctx .GetTable<GiveawayModel>() .CountAsync(x => x.GuildId == guildId); if (count >= 5) return null; var endsAt = DateTime.UtcNow + duration; var ga = await ctx.GetTable<GiveawayModel>() .InsertWithOutputAsync(() => new GiveawayModel { GuildId = guildId, MessageId = messageId, ChannelId = channelId, Message = message, EndsAt = endsAt, }); lock (_giveawayCache) { _giveawayCache.Add(ga); } return ga.Id; } public async Task<bool> EndGiveawayAsync(ulong guildId, int id) { await using var ctx = _db.GetDbContext(); var giveaway = await ctx .GetTable<GiveawayModel>() .Where(x => x.GuildId == guildId && x.Id == id) .LoadWith(x => x.Participants) .FirstOrDefaultAsyncLinqToDB(); if (giveaway is null) return false; await ctx .GetTable<GiveawayModel>() .Where(x => x.Id == id) .DeleteAsync(); lock (_giveawayCache) { _giveawayCache.Remove(giveaway); } var winner = PickWinner(giveaway); await OnGiveawayEnded(giveaway, winner); return true; } private GiveawayUser? PickWinner(GiveawayModel giveaway) { if (giveaway.Participants.Count == 0) return default; if (giveaway.Participants.Count == 1) { // as this is the last participant, rerolls no longer possible _cache.Remove($"reroll:{giveaway.Id}"); return giveaway.Participants[0]; } var winner = giveaway.Participants[_rng.Next(0, giveaway.Participants.Count - 1)]; HandleWinnerSelection(giveaway, winner); return winner; } public async Task<bool> RerollGiveawayAsync(ulong guildId, int giveawayId) { var rerollModel = _cache.Get<GiveawayRerollData>("reroll:" + giveawayId); if (rerollModel is null) return false; var winner = PickWinner(rerollModel.Giveaway); if (winner is not null) { await OnGiveawayEnded(rerollModel.Giveaway, winner); } return true; } public async Task<bool> CancelGiveawayAsync(ulong guildId, int id) { await using var ctx = _db.GetDbContext(); var ga = await ctx .GetTable<GiveawayModel>() .Where(x => x.GuildId == guildId && x.Id == id) .DeleteWithOutputAsync(); if (ga is not { Length: > 0 }) return false; lock (_giveawayCache) { _giveawayCache.Remove(ga[0]); } return true; } public async Task<IReadOnlyCollection<GiveawayModel>> GetGiveawaysAsync(ulong guildId) { await using var ctx = _db.GetDbContext(); return await ctx .GetTable<GiveawayModel>() .Where(x => x.GuildId == guildId) .ToListAsync(); } public async Task<bool> JoinGivawayAsync(ulong messageId, ulong userId, string userName) { await using var ctx = _db.GetDbContext(); var giveaway = await ctx .GetTable<GiveawayModel>() .Where(x => x.MessageId == messageId) .FirstOrDefaultAsyncLinqToDB(); if (giveaway is null) return false; // add the user to the database await ctx.GetTable<GiveawayUser>() .InsertAsync( () => new GiveawayUser() { UserId = userId, GiveawayId = giveaway.Id, Name = userName, } ); return true; } public async Task<bool> LeaveGivawayAsync(ulong messageId, ulong userId) { await using var ctx = _db.GetDbContext(); var giveaway = await ctx .GetTable<GiveawayModel>() .Where(x => x.MessageId == messageId) .FirstOrDefaultAsyncLinqToDB(); if (giveaway is null) return false; await ctx .GetTable<GiveawayUser>() .Where(x => x.UserId == userId && x.GiveawayId == giveaway.Id) .DeleteAsync(); return true; } public async Task OnGiveawayEnded(GiveawayModel ga, GiveawayUser? winner) { var culture = _localization.GetCultureInfo(ga.GuildId); string GetText(LocStr str) => _strings.GetText(str, culture); var ch = _client.GetChannel(ga.ChannelId) as ITextChannel; if (ch is null) return; var msg = await ch.GetMessageAsync(ga.MessageId) as IUserMessage; if (msg is null) return; var winnerStr = winner is null ? "-" : $""" {winner.Name} <@{winner.UserId}> {Format.Code(winner.UserId.ToString())} """; var eb = _sender.CreateEmbed(ch.GuildId) .WithOkColor() .WithTitle(GetText(strs.giveaway_ended)) .WithDescription(ga.Message) .WithFooter($"id: {new kwum(ga.Id).ToString()}") .AddField(GetText(strs.winner), winnerStr, true); try { await msg.ModifyAsync(x => x.Embed = eb.Build()); if (winner is not null) await _sender.Response(ch).Message(msg).Text($"🎉 <{winner.UserId}>").SendAsync(); } catch { _ = msg.DeleteAsync(); await _sender.Response(ch).Embed(eb).SendAsync(); } } private void HandleWinnerSelection(GiveawayModel ga, GiveawayUser winner) { ga.Participants = ga.Participants.Where(x => x.UserId != winner.UserId).ToList(); var rerollData = new GiveawayRerollData(ga); _cache.Set($"reroll:{ga.Id}", rerollData, TimeSpan.FromDays(1)); } } public sealed class GiveawayRerollData { public GiveawayModel Giveaway { get; init; } public DateTime ExpiresAt { get; init; } public GiveawayRerollData(GiveawayModel ga) { Giveaway = ga; ExpiresAt = DateTime.UtcNow.AddDays(1); } }