Added giveaway commands .ga start/end/cancel/reroll/list

This commit is contained in:
Kwoth
2024-04-24 22:31:45 +00:00
parent 59b9742c19
commit 49563ea6d4
16 changed files with 247 additions and 57 deletions

View File

@@ -9,6 +9,13 @@ Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.
- `.shopadd command` You can now sell commands in the shop. The command will execute as if you were the one running it when someone buys it
- type `.h .shopadd` for more info
- `.stickyroles` Users leaving the server will have their roles saved to the database and reapplied if they rejoin within 30 days.
- Giveaway commands
- `.ga start` starts the giveway with the specified duration and message (prize). You may have up to 5 giveaways on the server at once
- `.ga end <id>` prematurely ends the giveaway and selects a winner
- `.ga cancel <id>` cancels the giveaway and doesn't select the winner
- `.ga list` lists active giveaways on the current server
- `.ga reroll <id>` rerolls the winner on the completed giveaway. This only works for 24 hours after the giveaway has ended, or until the bot restarts.
- After the giveaway has started, user join the giveaway by adding a :tada: reaction
## [4.3.22] - 23.04.2023

View File

@@ -5,6 +5,7 @@ public sealed class GiveawayModel
public int Id { get; set; }
public ulong GuildId { get; set; }
public ulong MessageId { get; set; }
public ulong ChannelId { get; set; }
public string Message { get; set; }
public IList<GiveawayUser> Participants { get; set; } = new List<GiveawayUser>();

View File

@@ -36,6 +36,7 @@ public partial class Utility
}
eb
.WithOkColor()
.WithTitle(GetText(strs.giveaway_started))
.WithFooter($"id: {new kwum(id).ToString()}");
@@ -47,30 +48,32 @@ public partial class Utility
[UserPerm(GuildPerm.ManageMessages)]
public async Task GiveawayEnd(kwum id)
{
var (giveaway, winner) = await _service.EndGiveawayAsync(ctx.Guild.Id, id);
var success = await _service.EndGiveawayAsync(ctx.Guild.Id, id);
if (winner is null || giveaway is null)
if(!success)
{
await ReplyErrorLocalizedAsync(strs.giveaway_not_found);
return;
}
var eb = _eb.Create(ctx)
.WithOkColor()
.WithTitle(GetText(strs.giveaway_ended))
.WithDescription(giveaway.Message)
.AddField(GetText(strs.winner),
$"""
{winner.Name}
<@{winner.UserId}>
{Format.Code(winner.UserId.ToString())}
""",
true);
await ctx.Channel.EmbedAsync(eb);
await ctx.OkAsync();
_ = ctx.Message.DeleteAfter(5);
}
[Cmd]
[UserPerm(GuildPerm.ManageMessages)]
public async Task GiveawayReroll(kwum id)
{
var success = await _service.RerollGiveawayAsync(ctx.Guild.Id, id);
if (!success)
{
await ReplyErrorLocalizedAsync(strs.giveaway_not_found);
return;
}
await ctx.OkAsync();
_ = ctx.Message.DeleteAfter(5);
}
[Cmd]
@@ -101,6 +104,7 @@ public partial class Utility
}
var eb = _eb.Create(ctx)
.WithTitle(GetText(strs.giveaway_list))
.WithOkColor();
foreach (var g in giveaways)

View File

@@ -1,5 +1,6 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db.Models;
@@ -12,14 +13,24 @@ public sealed class GiveawayService : INService, IReadyExecutor
private readonly DbService _db;
private readonly IBotCredentials _creds;
private readonly DiscordSocketClient _client;
private GiveawayModel[] _shardGiveaways;
private readonly IEmbedBuilderService _eb;
private readonly IBotStrings _strings;
private readonly ILocalization _localization;
private readonly IMemoryCache _cache;
private SortedSet<GiveawayModel> _giveawayCache = new SortedSet<GiveawayModel>();
private readonly NadekoRandom _rng;
private readonly ConcurrentDictionary<int, GiveawayRerollData> _rerolls = new();
public GiveawayService(DbService db, IBotCredentials creds, DiscordSocketClient client)
public GiveawayService(DbService db, IBotCredentials creds, DiscordSocketClient client,
IEmbedBuilderService eb, IBotStrings strings, ILocalization localization, IMemoryCache cache)
{
_db = db;
_creds = creds;
_client = client;
_eb = eb;
_strings = strings;
_localization = localization;
_cache = cache;
_rng = new NadekoRandom();
@@ -73,10 +84,40 @@ public sealed class GiveawayService : INService, IReadyExecutor
// load giveaways for this shard from the database
await using var ctx = _db.GetDbContext();
_shardGiveaways = await ctx
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,
@@ -93,20 +134,26 @@ public sealed class GiveawayService : INService, IReadyExecutor
return null;
var endsAt = DateTime.UtcNow + duration;
var output = await ctx.GetTable<GiveawayModel>()
var ga = await ctx.GetTable<GiveawayModel>()
.InsertWithOutputAsync(() => new GiveawayModel
{
GuildId = guildId,
MessageId = messageId,
ChannelId = channelId,
Message = message,
EndsAt = endsAt,
});
return output.Id;
lock (_giveawayCache)
{
_giveawayCache.Add(ga);
}
return ga.Id;
}
public async Task<(GiveawayModel? giveaway, GiveawayUser? winner)> EndGiveawayAsync(ulong guildId, int id)
public async Task<bool> EndGiveawayAsync(ulong guildId, int id)
{
await using var ctx = _db.GetDbContext();
@@ -117,38 +164,78 @@ public sealed class GiveawayService : INService, IReadyExecutor
.FirstOrDefaultAsyncLinqToDB();
if (giveaway is null)
return default;
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)
return (giveaway, giveaway.Participants[0]);
return (giveaway, giveaway.Participants[_rng.Next(0, giveaway.Participants.Count - 1)]);
{
// as this is the last participant, rerolls no longer possible
_cache.Remove($"reroll:{giveaway.Id}");
return giveaway.Participants[0];
}
public async Task RerollGiveawayAsync(ulong guildId, int id)
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 count = await ctx
var ga = await ctx
.GetTable<GiveawayModel>()
.Where(x => x.GuildId == guildId && x.Id == id)
.DeleteAsync();
.DeleteWithOutputAsync();
// todo clear cache
if (ga is not { Length: > 0 })
return false;
return count > 0;
lock (_giveawayCache)
{
_giveawayCache.Remove(ga[0]);
}
return true;
}
public async Task<IReadOnlyCollection<GiveawayModel>> GetGiveawaysAsync(ulong guildId)
@@ -206,4 +293,69 @@ public sealed class GiveawayService : INService, IReadyExecutor
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 = _eb
.Create()
.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());
}
catch
{
_ = msg.DeleteAsync();
await ch.EmbedAsync(eb);
}
}
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);
}
}

View File

@@ -12,7 +12,7 @@ using Nadeko.Bot.Db;
namespace NadekoBot.Db.Migrations.Mysql
{
[DbContext(typeof(MysqlContext))]
[Migration("20240424152958_giveaway")]
[Migration("20240424203922_giveaway")]
partial class giveaway
{
/// <inheritdoc />
@@ -3085,6 +3085,10 @@ namespace NadekoBot.Db.Migrations.Mysql
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<ulong>("ChannelId")
.HasColumnType("bigint unsigned")
.HasColumnName("channelid");
b.Property<DateTime>("EndsAt")
.HasColumnType("datetime(6)")
.HasColumnName("endsat");

View File

@@ -20,6 +20,7 @@ namespace NadekoBot.Db.Migrations.Mysql
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
guildid = table.Column<ulong>(type: "bigint unsigned", nullable: false),
messageid = table.Column<ulong>(type: "bigint unsigned", nullable: false),
channelid = table.Column<ulong>(type: "bigint unsigned", nullable: false),
message = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
endsat = table.Column<DateTime>(type: "datetime(6)", nullable: false)

View File

@@ -3082,6 +3082,10 @@ namespace NadekoBot.Db.Migrations.Mysql
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<ulong>("ChannelId")
.HasColumnType("bigint unsigned")
.HasColumnName("channelid");
b.Property<DateTime>("EndsAt")
.HasColumnType("datetime(6)")
.HasColumnName("endsat");

View File

@@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace NadekoBot.Db.Migrations
{
[DbContext(typeof(PostgreSqlContext))]
[Migration("20240424152951_giveaway")]
[Migration("20240424203915_giveaway")]
partial class giveaway
{
/// <inheritdoc />
@@ -3084,6 +3084,10 @@ namespace NadekoBot.Db.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("ChannelId")
.HasColumnType("numeric(20,0)")
.HasColumnName("channelid");
b.Property<DateTime>("EndsAt")
.HasColumnType("timestamp without time zone")
.HasColumnName("endsat");

View File

@@ -20,6 +20,7 @@ namespace NadekoBot.Db.Migrations
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
messageid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
channelid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
message = table.Column<string>(type: "text", nullable: false),
endsat = table.Column<DateTime>(type: "timestamp without time zone", nullable: false)
},

View File

@@ -3081,6 +3081,10 @@ namespace NadekoBot.Db.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("ChannelId")
.HasColumnType("numeric(20,0)")
.HasColumnName("channelid");
b.Property<DateTime>("EndsAt")
.HasColumnType("timestamp without time zone")
.HasColumnName("endsat");

View File

@@ -11,7 +11,7 @@ using Nadeko.Bot.Db;
namespace NadekoBot.Db.Migrations.Sqlite
{
[DbContext(typeof(SqliteContext))]
[Migration("20240424152943_giveaway")]
[Migration("20240424203909_giveaway")]
partial class giveaway
{
/// <inheritdoc />
@@ -2289,6 +2289,9 @@ namespace NadekoBot.Db.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("ChannelId")
.HasColumnType("INTEGER");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");

View File

@@ -19,6 +19,7 @@ namespace NadekoBot.Db.Migrations.Sqlite
.Annotation("Sqlite:Autoincrement", true),
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
MessageId = table.Column<ulong>(type: "INTEGER", nullable: false),
ChannelId = table.Column<ulong>(type: "INTEGER", nullable: false),
Message = table.Column<string>(type: "TEXT", nullable: false),
EndsAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},

View File

@@ -2286,6 +2286,9 @@ namespace NadekoBot.Db.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("ChannelId")
.HasColumnType("INTEGER");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");

View File

@@ -2397,15 +2397,15 @@ giveawaystart:
- "15m Quick giveaway for a free course!"
- "1d Join to win 1000$!"
giveawayend:
desc: "Prematurely ends a giveaway and chooses a winner. Specify the ID of the giveaway to end."
desc: "Prematurely ends a giveaway and selects a winner. Specify the ID of the giveaway to end."
args:
- "ab3"
giveawaycancel:
desc: "Cancels a giveaway. Specify the ID of the giveaway to cancel."
desc: "Cancels a giveaway. Specify the ID of the giveaway to cancel. The winner will not be chosen."
args:
- "ab3"
giveawayreroll:
desc: "Rerolls a giveaway. Specify the ID of the giveaway to reroll. The winner will not be chosen."
desc: "Rerolls a giveaway. Specify the ID of the giveaway to reroll. This is only active within 24h after the giveaway has ended or until the bot restarts."
args:
- "cd3"
giveawaylist:

View File

@@ -1075,5 +1075,6 @@
"no_givaways": "There are no active giveaways on this server.",
"giveaway_cancelled": "Giveaway cancelled.",
"giveaway_starting": "Starting giveaway...",
"winner": "Winner"
"winner": "Winner",
"giveaway_list": "List of active giveways",
}

View File

@@ -1,4 +1,4 @@
dotnet ef migrations remove -c SqliteContext
dotnet ef migrations remove -c SqliteContext -f
dotnet ef migrations remove -c PostgreSqlContext -f
dotnet ef migrations remove -c MysqlContext -f