added addrolereward and removerolereward events for .notify

added .notify with no params showing events with descriptions
added .winlb
updated discord.net, redid migrations
This commit is contained in:
Toastie 2024-12-08 19:37:22 +13:00
parent f8eb585093
commit 29bac7739d
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
31 changed files with 635 additions and 219 deletions

View file

@ -2,6 +2,44 @@
Mostly based on [keepachangelog](https://keepachangelog.com/en/1.1.0/) except date format. a-c-f-r-o Mostly based on [keepachangelog](https://keepachangelog.com/en/1.1.0/) except date format. a-c-f-r-o
## [5.3.0] - 08.12.2024
## Added
- Added `.minesweeper` / `.mw` command - spoiler-based minesweeper minigame. Just for fun
- Added `.temprole` command - add a role to a user for a certain amount of time, after which the role will be removed
- Added `.xplevelset` - you can now set a level for a user in your server
- Added `.winlb` command - leaderboard of top gambling wins
- Added `.notify` command
- Specify an event to be notified about, and the bot will post the specified message in the current channel when the event occurs
- A few events supported right now:
- `UserLevelUp` when user levels up in the server
- `AddRoleReward` when a role is added to a user through .xpreward system
- `RemoveRoleReward` when a role is removed from a user through .xpreward system
- `Protection` when antialt, antiraid or antispam protection is triggered
- Added `.banner` command to see someone's banner
- Selfhosters:
- Added `.dmmod` and `.dmcmd` - you can now disable or enable whether commands or modules can be executed in bot's DMs
## Changed
- Giveaway improvements
- Now mentions winners in a separate message
- Shows the timestamp of when the giveaway ends
- Xp Changes
- Removed awarded xp (the number in the brackets on the xp card)
- Awarded xp, (or the new level set) now directly apply to user's real xp
- Server xp notifications are now set by the server admin/manager in a specified channel
- `.sclr show` will now show hex code of the current color
- Queueing a song will now restart the playback if the queue is on the last track and stopped (there were no more tracks to play)
## Fixed
- .setstream and .setactivity will now pause .ropl (rotating statuses)
## Removed
## [5.2.4] - 29.11.2024 ## [5.2.4] - 29.11.2024
## Fixed ## Fixed

View file

@ -9,7 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Discord.Net.Core" Version="3.15.3" /> <PackageReference Include="Discord.Net.Core" Version="3.16.0" />
<PackageReference Include="Serilog" Version="3.1.1" /> <PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="YamlDotNet" Version="15.1.4" /> <PackageReference Include="YamlDotNet" Version="15.1.4" />
</ItemGroup> </ItemGroup>

View file

@ -164,13 +164,18 @@ public abstract class EllieContext : DbContext
#region UserBetStats #region UserBetStats
modelBuilder.Entity<UserBetStats>() modelBuilder.Entity<UserBetStats>(ubs =>
.HasIndex(x => new {
{ ubs.HasIndex(x => new
x.UserId, {
x.Game x.UserId,
}) x.Game
.IsUnique(); })
.IsUnique();
ubs.HasIndex(x => x.MaxWin)
.IsUnique(false);
});
#endregion #endregion

View file

@ -19,4 +19,6 @@ public enum NotifyType
{ {
LevelUp = 0, LevelUp = 0,
Protection = 1, Prot = 1, Protection = 1, Prot = 1,
AddRoleReward = 2,
RemoveRoleReward = 3,
} }

View file

@ -29,7 +29,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="CodeHollow.FeedReader" Version="1.2.6" /> <PackageReference Include="CodeHollow.FeedReader" Version="1.2.6" />
<PackageReference Include="CommandLineParser" Version="2.9.1" /> <PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Discord.Net" Version="3.15.3" /> <PackageReference Include="Discord.Net" Version="3.16.0" />
<PackageReference Include="CoreCLR-NCalc" Version="3.1.246" /> <PackageReference Include="CoreCLR-NCalc" Version="3.1.246" />
<PackageReference Include="Google.Apis.Urlshortener.v1" Version="1.41.1.138" /> <PackageReference Include="Google.Apis.Urlshortener.v1" Version="1.41.1.138" />
<PackageReference Include="Google.Apis.YouTube.v3" Version="1.68.0.3414" /> <PackageReference Include="Google.Apis.YouTube.v3" Version="1.68.0.3414" />

View file

@ -5,6 +5,16 @@ namespace EllieBot.Migrations;
public static class MigrationQueries public static class MigrationQueries
{ {
public static void MergeAwardedXp(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE UserXpStats
SET Xp = AwardedXp + Xp,
AwardedXp = 0
WHERE AwardedXp > 0;
""");
}
public static void MigrateSar(MigrationBuilder migrationBuilder) public static void MigrateSar(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.Sql(""" migrationBuilder.Sql("""

View file

@ -1,46 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EllieBot.Migrations.PostgreSql
{
/// <inheritdoc />
public partial class awardedxptemprolenotify : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropUniqueConstraint(
name: "ak_notify_guildid_event",
table: "notify");
migrationBuilder.RenameColumn(
name: "event",
table: "notify",
newName: "type");
migrationBuilder.AddUniqueConstraint(
name: "ak_notify_guildid_type",
table: "notify",
columns: new[] { "guildid", "type" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropUniqueConstraint(
name: "ak_notify_guildid_type",
table: "notify");
migrationBuilder.RenameColumn(
name: "type",
table: "notify",
newName: "event");
migrationBuilder.AddUniqueConstraint(
name: "ak_notify_guildid_event",
table: "notify",
columns: new[] { "guildid", "event" });
}
}
}

View file

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace EllieBot.Migrations.PostgreSql namespace EllieBot.Migrations.PostgreSql
{ {
[DbContext(typeof(PostgreSqlContext))] [DbContext(typeof(PostgreSqlContext))]
[Migration("20241208053644_awardedxp-temprole-notify")] [Migration("20241208063342_awardedxp-temprole-notify")]
partial class awardedxptemprolenotify partial class awardedxptemprolenotify
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -3485,6 +3485,9 @@ namespace EllieBot.Migrations.PostgreSql
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_userbetstats"); .HasName("pk_userbetstats");
b.HasIndex("MaxWin")
.HasDatabaseName("ix_userbetstats_maxwin");
b.HasIndex("UserId", "Game") b.HasIndex("UserId", "Game")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_userbetstats_userid_game"); .HasDatabaseName("ix_userbetstats_userid_game");

View file

@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EllieBot.Migrations.PostgreSql
{
/// <inheritdoc />
public partial class awardedxptemprolenotify : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "ix_userbetstats_maxwin",
table: "userbetstats",
column: "maxwin");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_userbetstats_maxwin",
table: "userbetstats");
}
}
}

View file

@ -3482,6 +3482,9 @@ namespace EllieBot.Migrations.PostgreSql
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_userbetstats"); .HasName("pk_userbetstats");
b.HasIndex("MaxWin")
.HasDatabaseName("ix_userbetstats_maxwin");
b.HasIndex("UserId", "Game") b.HasIndex("UserId", "Game")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_userbetstats_userid_game"); .HasDatabaseName("ix_userbetstats_userid_game");

View file

@ -1,46 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EllieBot.Migrations
{
/// <inheritdoc />
public partial class awardedxptemprolenotify : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropUniqueConstraint(
name: "AK_Notify_GuildId_Event",
table: "Notify");
migrationBuilder.RenameColumn(
name: "Event",
table: "Notify",
newName: "Type");
migrationBuilder.AddUniqueConstraint(
name: "AK_Notify_GuildId_Type",
table: "Notify",
columns: new[] { "GuildId", "Type" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropUniqueConstraint(
name: "AK_Notify_GuildId_Type",
table: "Notify");
migrationBuilder.RenameColumn(
name: "Type",
table: "Notify",
newName: "Event");
migrationBuilder.AddUniqueConstraint(
name: "AK_Notify_GuildId_Event",
table: "Notify",
columns: new[] { "GuildId", "Event" });
}
}
}

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace EllieBot.Migrations namespace EllieBot.Migrations
{ {
[DbContext(typeof(SqliteContext))] [DbContext(typeof(SqliteContext))]
[Migration("20241208053549_awardedxp-temprole-notify")] [Migration("20241208063257_awardedxp-temprole-notify")]
partial class awardedxptemprolenotify partial class awardedxptemprolenotify
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -2593,6 +2593,8 @@ namespace EllieBot.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("MaxWin");
b.HasIndex("UserId", "Game") b.HasIndex("UserId", "Game")
.IsUnique(); .IsUnique();

View file

@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EllieBot.Migrations
{
/// <inheritdoc />
public partial class awardedxptemprolenotify : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_UserBetStats_MaxWin",
table: "UserBetStats",
column: "MaxWin");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserBetStats_MaxWin",
table: "UserBetStats");
}
}
}

View file

@ -2590,6 +2590,8 @@ namespace EllieBot.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("MaxWin");
b.HasIndex("UserId", "Game") b.HasIndex("UserId", "Game")
.IsUnique(); .IsUnique();

View file

@ -0,0 +1,36 @@
using EllieBot.Db.Models;
using EllieBot.Modules.Administration;
namespace EllieBot.Modules.Xp.Services;
public record struct AddRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ulong UserId, long Level) : INotifyModel
{
public static string KeyName
=> "notify.reward.addrole";
public static NotifyType NotifyType
=> NotifyType.AddRoleReward;
public IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements()
{
var model = this;
return new Dictionary<string, Func<SocketGuild, string>>()
{
{ "%event.user%", g => g.GetUser(model.UserId)?.ToString() ?? model.UserId.ToString() },
{ "%event.role%", g => g.GetRole(model.RoleId)?.ToString() ?? model.RoleId.ToString() },
{ "%event.level%", g => model.Level.ToString() }
};
}
public bool TryGetUserId(out ulong userId)
{
userId = UserId;
return true;
}
public bool TryGetGuildId(out ulong guildId)
{
guildId = GuildId;
return true;
}
}

View file

@ -20,6 +20,7 @@ public record struct LevelUpNotifyModel(
return new Dictionary<string, Func<SocketGuild, string>>() return new Dictionary<string, Func<SocketGuild, string>>()
{ {
{ "%event.level%", g => data.Level.ToString() }, { "%event.level%", g => data.Level.ToString() },
{ "%event.user%", g => g.GetUser(data.UserId)?.ToString() ?? data.UserId.ToString() },
}; };
} }
@ -34,11 +35,4 @@ public record struct LevelUpNotifyModel(
userId = UserId; userId = UserId;
return true; return true;
} }
}
public static class INotifyModelExtensions
{
public static TypedKey<T> GetTypedKey<T>(this T model)
where T : struct, INotifyModel
=> new(T.KeyName);
} }

View file

@ -0,0 +1,34 @@
#nullable disable
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.Services;
public record struct ProtectionNotifyModel(ulong GuildId, ProtectionType ProtType, ulong UserId) : INotifyModel
{
public static string KeyName
=> "notify.protection";
public static NotifyType NotifyType
=> NotifyType.Protection;
public IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements()
{
var data = this;
return new Dictionary<string, Func<SocketGuild, string>>()
{
{ "%event.type%", g => data.ProtType.ToString() },
};
}
public bool TryGetUserId(out ulong userId)
{
userId = UserId;
return true;
}
public bool TryGetGuildId(out ulong guildId)
{
guildId = GuildId;
return true;
}
}

View file

@ -0,0 +1,36 @@
using EllieBot.Db.Models;
using EllieBot.Modules.Administration;
namespace EllieBot.Modules.Xp.Services;
public record struct RemoveRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ulong UserId, long Level) : INotifyModel
{
public static string KeyName
=> "notify.reward.removerole";
public static NotifyType NotifyType
=> NotifyType.RemoveRoleReward;
public IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements()
{
var model = this;
return new Dictionary<string, Func<SocketGuild, string>>()
{
{ "%event.user%", g => g.GetUser(model.UserId)?.ToString() ?? model.UserId.ToString() },
{ "%event.role%", g => g.GetRole(model.RoleId)?.ToString() ?? model.RoleId.ToString() },
{ "%event.level%", g => model.Level.ToString() },
};
}
public bool TryGetUserId(out ulong userId)
{
userId = UserId;
return true;
}
public bool TryGetGuildId(out ulong guildId)
{
guildId = GuildId;
return true;
}
}

View file

@ -1,4 +1,5 @@
using EllieBot.Db.Models; using EllieBot.Db.Models;
using System.Text;
namespace EllieBot.Modules.Administration; namespace EllieBot.Modules.Administration;
@ -6,19 +7,108 @@ public partial class Administration
{ {
public class NotifyCommands : EllieModule<NotifyService> public class NotifyCommands : EllieModule<NotifyService>
{ {
[Cmd]
[OwnerOnly]
public async Task Notify()
{
await Response()
.Paginated()
.Items(Enum.GetValues<NotifyType>())
.PageSize(5)
.Page((items, page) =>
{
var eb = CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.notify_available));
foreach (var item in items)
{
eb.AddField(item.ToString(), GetText(GetDescription(item)), false);
}
return eb;
})
.SendAsync();
}
private LocStr GetDescription(NotifyType item)
=> item switch
{
NotifyType.LevelUp => strs.notify_desc_levelup,
NotifyType.Protection => strs.notify_desc_protection,
NotifyType.AddRoleReward => strs.notify_desc_addrolerew,
NotifyType.RemoveRoleReward => strs.notify_desc_removerolerew,
_ => strs.notify_desc_not_found
};
[Cmd] [Cmd]
[OwnerOnly] [OwnerOnly]
public async Task Notify(NotifyType nType, [Leftover] string? message = null) public async Task Notify(NotifyType nType, [Leftover] string? message = null)
{ {
if (string.IsNullOrWhiteSpace(message)) if (string.IsNullOrWhiteSpace(message))
{ {
await _service.DisableAsync(ctx.Guild.Id, nType); // show msg
await Response().Confirm(strs.notify_off(nType)).SendAsync(); var conf = await _service.GetNotifyAsync(ctx.Guild.Id, nType);
if (conf is null)
{
await Response().Confirm(strs.notify_msg_not_set).SendAsync();
return;
}
var eb = CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.notify_msg))
.WithDescription(conf.Message.TrimTo(2048))
.AddField(GetText(strs.notify_type), conf.Type.ToString(), true)
.AddField(GetText(strs.channel),
$"""
<#{conf.ChannelId}>
`{conf.ChannelId}`
""",
true);
await Response().Embed(eb).SendAsync();
return; return;
} }
await _service.EnableAsync(ctx.Guild.Id, ctx.Channel.Id, nType, message); await _service.EnableAsync(ctx.Guild.Id, ctx.Channel.Id, nType, message);
await Response().Confirm(strs.notify_on(nType.ToString())).SendAsync(); await Response().Confirm(strs.notify_on($"<#{ctx.Channel.Id}>", Format.Bold(nType.ToString()))).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task NotifyList(int page = 1)
{
if (--page < 0)
return;
var notifs = await _service.GetForGuildAsync(ctx.Guild.Id);
var sb = new StringBuilder();
foreach (var notif in notifs)
{
sb.AppendLine($"""
- **{notif.Type}**
<#{notif.ChannelId}> `{notif.ChannelId}`
""");
}
if (notifs.Count == 0)
sb.AppendLine(GetText(strs.notify_none));
await Response()
.Confirm(GetText(strs.notify_list), text: sb.ToString())
.SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task NotifyClear(NotifyType nType)
{
await _service.DisableAsync(ctx.Guild.Id, nType);
await Response().Confirm(strs.notify_off(nType)).SendAsync();
} }
} }
} }

View file

@ -0,0 +1,8 @@
namespace EllieBot.Modules.Administration;
public static class NotifyModelExtensions
{
public static TypedKey<T> GetTypedKey<T>(this T model)
where T : struct, INotifyModel
=> new(T.KeyName);
}

View file

@ -2,6 +2,7 @@
using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors; using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models; using EllieBot.Db.Models;
using EllieBot.Generators;
namespace EllieBot.Modules.Administration; namespace EllieBot.Modules.Administration;
@ -199,4 +200,27 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
guildsDict.TryRemove(guildId, out _); guildsDict.TryRemove(guildId, out _);
} }
public async Task<IReadOnlyCollection<Notify>> GetForGuildAsync(ulong guildId, int page = 0)
{
ArgumentOutOfRangeException.ThrowIfNegative(page);
await using var ctx = _db.GetDbContext();
var list = await ctx.GetTable<Notify>()
.Where(x => x.GuildId == guildId)
.OrderBy(x => x.Type)
.Skip(page * 10)
.Take(10)
.ToListAsyncLinqToDB();
return list;
}
public async Task<Notify?> GetNotifyAsync(ulong guildId, NotifyType nType)
{
await using var ctx = _db.GetDbContext();
return await ctx.GetTable<Notify>()
.Where(x => x.GuildId == guildId && x.Type == nType)
.FirstOrDefaultAsyncLinqToDB();
}
} }

View file

@ -5,36 +5,6 @@ using System.Threading.Channels;
namespace EllieBot.Modules.Administration.Services; namespace EllieBot.Modules.Administration.Services;
public record struct ProtectionNotifyModel(ulong GuildId, ProtectionType ProtType, ulong UserId) : INotifyModel
{
public static string KeyName
=> "notify.protection";
public static NotifyType NotifyType
=> NotifyType.Protection;
public IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements()
{
var data = this;
return new Dictionary<string, Func<SocketGuild, string>>()
{
{ "%event.type%", g => data.ProtType.ToString() },
};
}
public bool TryGetUserId(out ulong userId)
{
userId = UserId;
return true;
}
public bool TryGetGuildId(out ulong guildId)
{
guildId = GuildId;
return true;
}
}
public class ProtectionService : IEService public class ProtectionService : IEService
{ {
public event Func<PunishmentAction, ProtectionType, IGuildUser[], Task> OnAntiProtectionTriggered = delegate public event Func<PunishmentAction, ProtectionType, IGuildUser[], Task> OnAntiProtectionTriggered = delegate

View file

@ -1,6 +1,7 @@
#nullable disable #nullable disable
using EllieBot.Modules.Gambling.Common; using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Services; using EllieBot.Modules.Gambling.Services;
using EllieBot.Modules.Xp.Services;
namespace EllieBot.Modules.Gambling; namespace EllieBot.Modules.Gambling;
@ -10,13 +11,19 @@ public partial class Gambling
public sealed class BetStatsCommands : GamblingModule<UserBetStatsService> public sealed class BetStatsCommands : GamblingModule<UserBetStatsService>
{ {
private readonly GamblingTxTracker _gamblingTxTracker; private readonly GamblingTxTracker _gamblingTxTracker;
private readonly IBotCache _cache;
private readonly IUserService _userService;
public BetStatsCommands( public BetStatsCommands(
GamblingTxTracker gamblingTxTracker, GamblingTxTracker gamblingTxTracker,
GamblingConfigService gcs) GamblingConfigService gcs,
IBotCache cache,
IUserService userService)
: base(gcs) : base(gcs)
{ {
_gamblingTxTracker = gamblingTxTracker; _gamblingTxTracker = gamblingTxTracker;
_cache = cache;
_userService = userService;
} }
[Cmd] [Cmd]
@ -25,12 +32,12 @@ public partial class Gambling
var price = await _service.GetResetStatsPriceAsync(ctx.User.Id, game); var price = await _service.GetResetStatsPriceAsync(ctx.User.Id, game);
var result = await PromptUserConfirmAsync(CreateEmbed() var result = await PromptUserConfirmAsync(CreateEmbed()
.WithDescription( .WithDescription(
$""" $"""
Are you sure you want to reset your bet stats for **{GetGameName(game)}**? Are you sure you want to reset your bet stats for **{GetGameName(game)}**?
It will cost you {N(price)} It will cost you {N(price)}
""")); """));
if (!result) if (!result)
return; return;
@ -88,15 +95,15 @@ public partial class Gambling
}; };
var eb = CreateEmbed() var eb = CreateEmbed()
.WithOkColor() .WithOkColor()
.WithAuthor(user) .WithAuthor(user)
.AddField("Total Won", N(stats.Sum(x => x.PaidOut)), true) .AddField("Total Won", N(stats.Sum(x => x.PaidOut)), true)
.AddField("Biggest Win", N(stats.Max(x => x.MaxWin)), true) .AddField("Biggest Win", N(stats.Max(x => x.MaxWin)), true)
.AddField("Biggest Bet", N(stats.Max(x => x.MaxBet)), true) .AddField("Biggest Bet", N(stats.Max(x => x.MaxBet)), true)
.AddField("# Bets", stats.Sum(x => x.WinCount + x.LoseCount), true) .AddField("# Bets", stats.Sum(x => x.WinCount + x.LoseCount), true)
.AddField("Payout", .AddField("Payout",
(stats.Sum(x => x.PaidOut) / stats.Sum(x => x.TotalBet)).ToString("P2", Culture), (stats.Sum(x => x.PaidOut) / stats.Sum(x => x.TotalBet)).ToString("P2", Culture),
true); true);
if (game == null) if (game == null)
{ {
var favGame = stats.MaxBy(x => x.WinCount + x.LoseCount); var favGame = stats.MaxBy(x => x.WinCount + x.LoseCount);
@ -115,23 +122,85 @@ public partial class Gambling
.SendAsync(); .SendAsync();
} }
private readonly record struct WinLbStat(
int Rank,
string User,
GamblingGame Game,
long MaxWin);
private TypedKey<List<WinLbStat>> GetWinLbKey(int page)
=> new($"winlb:{page}");
private async Task<IReadOnlyCollection<WinLbStat>> GetCachedWinLbAsync(int page)
{
return await _cache.GetOrAddAsync(GetWinLbKey(page),
async () =>
{
var items = await _service.GetWinLbAsync(page);
if (items.Count == 0)
return [];
var outputItems = new List<WinLbStat>(items.Count);
for (var i = 0; i < items.Count; i++)
{
var x = items[i];
var user = (await ctx.Client.GetUserAsync(x.UserId, CacheMode.CacheOnly))?.ToString()
?? (await _userService.GetUserAsync(x.UserId))?.Username
?? x.UserId.ToString();
outputItems.Add(new WinLbStat(i + 1 + (page * 10), user, x.Game, x.MaxWin));
}
return outputItems;
},
expiry: TimeSpan.FromMinutes(5));
}
[Cmd]
public async Task WinLb(int page = 1)
{
if (--page < 0)
return;
await Response()
.Paginated()
.PageItems(p => GetCachedWinLbAsync(p))
.PageSize(10)
.Page((items, curPage) =>
{
var eb = CreateEmbed()
.WithOkColor();
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
eb.AddField($"#{item.Rank} {item.User}",
$"[{item.Game}]{N(item.MaxWin)}");
}
return eb;
})
.SendAsync();
}
[Cmd] [Cmd]
public async Task GambleStats() public async Task GambleStats()
{ {
var stats = await _gamblingTxTracker.GetAllAsync(); var stats = await _gamblingTxTracker.GetAllAsync();
var eb = CreateEmbed() var eb = CreateEmbed()
.WithOkColor(); .WithOkColor();
var str = "` Feature `` Bet ``Paid Out`` RoI `\n"; var str = "` Feature `` Bet ``Paid Out`` RoI `\n";
str += "――――――――――――――――――――\n"; str += "――――――――――――――――――――\n";
foreach (var stat in stats) foreach (var stat in stats)
{ {
var perc = (stat.PaidOut / stat.Bet).ToString("P2", Culture); var perc = (stat.PaidOut / stat.Bet).ToString("P2", Culture);
str += $"`{stat.Feature.PadBoth(9)}`" str += $"`{stat.Feature.PadBoth(9)}`"
+ $"`{stat.Bet.ToString("N0").PadLeft(8, ' ')}`" + $"`{stat.Bet.ToString("N0").PadLeft(8, '')}`"
+ $"`{stat.PaidOut.ToString("N0").PadLeft(8, ' ')}`" + $"`{stat.PaidOut.ToString("N0").PadLeft(8, '')}`"
+ $"`{perc.PadLeft(6, ' ')}`\n"; + $"`{perc.PadLeft(6, '')}`\n";
} }
var bet = stats.Sum(x => x.Bet); var bet = stats.Sum(x => x.Bet);
@ -143,9 +212,9 @@ public partial class Gambling
var tPerc = (paidOut / bet).ToString("P2", Culture); var tPerc = (paidOut / bet).ToString("P2", Culture);
str += "――――――――――――――――――――\n"; str += "――――――――――――――――――――\n";
str += $"` {("TOTAL").PadBoth(7)}` " str += $"` {("TOTAL").PadBoth(7)}` "
+ $"**{N(bet).PadLeft(8, ' ')}**" + $"**{N(bet).PadLeft(8, '')}**"
+ $"**{N(paidOut).PadLeft(8, ' ')}**" + $"**{N(paidOut).PadLeft(8, '')}**"
+ $"`{tPerc.PadLeft(6, ' ')}`"; + $"`{tPerc.PadLeft(6, '')}`";
eb.WithDescription(str); eb.WithDescription(str);
@ -157,13 +226,13 @@ public partial class Gambling
public async Task GambleStatsReset() public async Task GambleStatsReset()
{ {
if (!await PromptUserConfirmAsync(CreateEmbed() if (!await PromptUserConfirmAsync(CreateEmbed()
.WithDescription( .WithDescription(
""" """
Are you sure? Are you sure?
This will completely reset Gambling Stats. This will completely reset Gambling Stats.
This action is irreversible. This action is irreversible.
"""))) """)))
return; return;
await GambleStats(); await GambleStats();

View file

@ -52,4 +52,16 @@ public sealed class UserBetStatsService : IEService
await ctx.GetTable<GamblingStats>() await ctx.GetTable<GamblingStats>()
.DeleteAsync(); .DeleteAsync();
} }
public async Task<IReadOnlyList<UserBetStats>> GetWinLbAsync(int page)
{
ArgumentOutOfRangeException.ThrowIfNegative(page);
await using var ctx = _db.GetDbContext();
return await ctx.GetTable<UserBetStats>()
.OrderByDescending(x => x.MaxWin)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
} }

View file

@ -103,11 +103,11 @@ public partial class Searches : EllieModule<SearchesService>
} }
var eb = CreateEmbed() var eb = CreateEmbed()
.WithOkColor() .WithOkColor()
.WithTitle(GetText(strs.time_new)) .WithTitle(GetText(strs.time_new))
.WithDescription(Format.Code(data.Time.ToString(Culture))) .WithDescription(Format.Code(data.Time.ToString(Culture)))
.AddField(GetText(strs.location), string.Join('\n', data.Address.Split(", ")), true) .AddField(GetText(strs.location), string.Join('\n', data.Address.Split(", ")), true)
.AddField(GetText(strs.timezone), data.TimeZoneName, true); .AddField(GetText(strs.timezone), data.TimeZoneName, true);
await Response().Embed(eb).SendAsync(); await Response().Embed(eb).SendAsync();
} }
@ -129,16 +129,16 @@ public partial class Searches : EllieModule<SearchesService>
await Response() await Response()
.Embed(CreateEmbed() .Embed(CreateEmbed()
.WithOkColor() .WithOkColor()
.WithTitle(movie.Title) .WithTitle(movie.Title)
.WithUrl($"https://www.imdb.com/title/{movie.ImdbId}/") .WithUrl($"https://www.imdb.com/title/{movie.ImdbId}/")
.WithDescription(movie.Plot.TrimTo(1000)) .WithDescription(movie.Plot.TrimTo(1000))
.AddField("Rating", movie.ImdbRating, true) .AddField("Rating", movie.ImdbRating, true)
.AddField("Genre", movie.Genre, true) .AddField("Genre", movie.Genre, true)
.AddField("Year", movie.Year, true) .AddField("Year", movie.Year, true)
.WithImageUrl(Uri.IsWellFormedUriString(movie.Poster, UriKind.Absolute) .WithImageUrl(Uri.IsWellFormedUriString(movie.Poster, UriKind.Absolute)
? movie.Poster ? movie.Poster
: null)) : null))
.SendAsync(); .SendAsync();
} }
@ -191,9 +191,9 @@ public partial class Searches : EllieModule<SearchesService>
await Response() await Response()
.Embed(CreateEmbed() .Embed(CreateEmbed()
.WithOkColor() .WithOkColor()
.AddField(GetText(strs.original_url), $"<{query}>") .AddField(GetText(strs.original_url), $"<{query}>")
.AddField(GetText(strs.short_url), $"<{shortLink}>")) .AddField(GetText(strs.short_url), $"<{shortLink}>"))
.SendAsync(); .SendAsync();
} }
@ -214,13 +214,13 @@ public partial class Searches : EllieModule<SearchesService>
} }
var embed = CreateEmbed() var embed = CreateEmbed()
.WithOkColor() .WithOkColor()
.WithTitle(card.Name) .WithTitle(card.Name)
.WithDescription(card.Description) .WithDescription(card.Description)
.WithImageUrl(card.ImageUrl) .WithImageUrl(card.ImageUrl)
.AddField(GetText(strs.store_url), card.StoreUrl, true) .AddField(GetText(strs.store_url), card.StoreUrl, true)
.AddField(GetText(strs.cost), card.ManaCost, true) .AddField(GetText(strs.cost), card.ManaCost, true)
.AddField(GetText(strs.types), card.Types, true); .AddField(GetText(strs.types), card.Types, true);
await Response().Embed(embed).SendAsync(); await Response().Embed(embed).SendAsync();
} }
@ -281,10 +281,10 @@ public partial class Searches : EllieModule<SearchesService>
{ {
var item = items[0]; var item = items[0];
return CreateEmbed() return CreateEmbed()
.WithOkColor() .WithOkColor()
.WithUrl(item.Permalink) .WithUrl(item.Permalink)
.WithTitle(item.Word) .WithTitle(item.Word)
.WithDescription(item.Definition); .WithDescription(item.Definition);
}) })
.SendAsync(); .SendAsync();
} }
@ -312,11 +312,11 @@ public partial class Searches : EllieModule<SearchesService>
{ {
var model = items.First(); var model = items.First();
var embed = CreateEmbed() var embed = CreateEmbed()
.WithDescription(ctx.User.Mention) .WithDescription(ctx.User.Mention)
.AddField(GetText(strs.word), model.Word, true) .AddField(GetText(strs.word), model.Word, true)
.AddField(GetText(strs._class), model.WordType, true) .AddField(GetText(strs._class), model.WordType, true)
.AddField(GetText(strs.definition), model.Definition) .AddField(GetText(strs.definition), model.Definition)
.WithOkColor(); .WithOkColor();
if (!string.IsNullOrWhiteSpace(model.Example)) if (!string.IsNullOrWhiteSpace(model.Example))
embed.AddField(GetText(strs.example), model.Example); embed.AddField(GetText(strs.example), model.Example);
@ -404,10 +404,28 @@ public partial class Searches : EllieModule<SearchesService>
await Response() await Response()
.Embed( .Embed(
CreateEmbed() CreateEmbed()
.WithOkColor() .WithOkColor()
.AddField("Username", usr.ToString()) .AddField("Username", usr.ToString())
.AddField("Avatar Url", avatarUrl) .AddField("Avatar Url", avatarUrl)
.WithThumbnailUrl(avatarUrl.ToString())) .WithThumbnailUrl(avatarUrl.ToString()))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Banner([Leftover] IGuildUser? usr = null)
{
usr ??= (IGuildUser)ctx.User;
var bannerUrl = usr.GetGuildBannerUrl();
await Response()
.Embed(
CreateEmbed()
.WithOkColor()
.AddField("Username", usr.ToString())
.AddField("Banner Url", bannerUrl)
.WithThumbnailUrl(bannerUrl))
.SendAsync(); .SendAsync();
} }

View file

@ -344,9 +344,45 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
if (role is not null && user is not null) if (role is not null && user is not null)
{ {
if (rrew.Remove) if (rrew.Remove)
_ = user.RemoveRoleAsync(role); {
try
{
await user.RemoveRoleAsync(role);
await _notifySub.NotifyAsync(new RemoveRoleRewardNotifyModel(guild.Id,
role.Id,
user.Id,
newLevel),
isShardLocal: true);
}
catch (Exception ex)
{
Log.Warning(ex,
"Unable to remove role {RoleId} from user {UserId}: {Message}",
role.Id,
user.Id,
ex.Message);
}
}
else else
_ = user.AddRoleAsync(role); {
try
{
await user.AddRoleAsync(role);
await _notifySub.NotifyAsync(new AddRoleRewardNotifyModel(guild.Id,
role.Id,
user.Id,
newLevel),
isShardLocal: true);
}
catch (Exception ex)
{
Log.Warning(ex,
"Unable to add role {RoleId} to user {UserId}: {Message}",
role.Id,
user.Id,
ex.Message);
}
}
} }
} }

View file

@ -157,6 +157,9 @@ public sealed class DoAsUserMessage : IUserMessage
public MessageCallData? CallData public MessageCallData? CallData
=> _msg.CallData; => _msg.CallData;
public IReadOnlyCollection<MessageSnapshot> ForwardedMessages
=> _msg.ForwardedMessages;
public Task ModifyAsync(Action<MessageProperties> func, RequestOptions? options = null) public Task ModifyAsync(Action<MessageProperties> func, RequestOptions? options = null)
{ {
return _msg.ModifyAsync(func, options); return _msg.ModifyAsync(func, options);

View file

@ -12,7 +12,7 @@ public sealed class UserService : IUserService, IEService
_db = db; _db = db;
} }
public async Task<DiscordUser> GetUserAsync(ulong userId) public async Task<DiscordUser?> GetUserAsync(ulong userId)
{ {
await using var uow = _db.GetDbContext(); await using var uow = _db.GetDbContext();
var user = await uow var user = await uow

View file

@ -715,6 +715,8 @@ color:
avatar: avatar:
- avatar - avatar
- av - av
banner:
- banner
translate: translate:
- translate - translate
- trans - trans
@ -1549,4 +1551,15 @@ temprole:
- temprole - temprole
notify: notify:
- notify - notify
- nfy - nfy
notifylist:
- notifylist
- notifyl
notifyclear:
- notifyclear
- notifyremove
- notifyrm
- notifclr
winlb:
- winlb
- wins

View file

@ -2170,6 +2170,13 @@ avatar:
params: params:
- usr: - usr:
desc: "The user whose avatar is being displayed." desc: "The user whose avatar is being displayed."
banner:
desc: Shows a mentioned person's banner.
ex:
- '@Someone'
params:
- usr:
desc: "The user whose banner is being displayed."
translate: translate:
desc: Translates text from the given language to the destination language. desc: Translates text from the given language to the destination language.
ex: ex:
@ -4857,10 +4864,38 @@ minesweeper:
notify: notify:
desc: |- desc: |-
Sends a message to the current channel once the specified event occurs. Sends a message to the current channel once the specified event occurs.
Provide no parameters to see all available events.
ex: ex:
- 'levelup Congratulations to user %user.name% for reaching level %event.level%' - 'levelup Congratulations to user %user.name% for reaching level %event.level%'
params: params:
- { }
- event: - event:
desc: "The event to notify on." desc: "The event to notify on."
- message: - event:
desc: "The message to send." desc: "The event to notify on."
message:
desc: "The message to send."
notifylist:
desc: |-
Lists all active notifications in this server.
ex:
- ''
params:
- { }
notifyclear:
desc: |-
Removes the specified notify event.
ex:
- 'levelup'
params:
- event:
desc: "The notify event to clear."
winlb:
desc: |-
Shows the biggest wins leaderboard
ex:
- ''
- '5'
params:
- page:
desc: "The optional page to display."

View file

@ -1128,7 +1128,7 @@
"choose_one": "Choose one", "choose_one": "Choose one",
"requires_role": "Requires role: {0}", "requires_role": "Requires role: {0}",
"invalid_message_id": "Invalid Message Id.", "invalid_message_id": "Invalid Message Id.",
"invalid_message_link": "The message link must be from this server.", "invalid_message_link": "The message link must be this Bot's message. The bot can't add buttons to other users' messages.",
"btnrole_message_max": "Limit reached. You may have up to 25 button roles per message.", "btnrole_message_max": "Limit reached. You may have up to 25 button roles per message.",
"btnrole_not_found": "No button role found on that message.", "btnrole_not_found": "No button role found on that message.",
"btnrole_none": "There are no button roles on this page.", "btnrole_none": "There are no button roles on this page.",
@ -1145,6 +1145,17 @@
"level_set": "Level of user {0} set to {1} on this server.", "level_set": "Level of user {0} set to {1} on this server.",
"temp_role_added": "User {0} has been given {1} role temporarily. The role expires {2}", "temp_role_added": "User {0} has been given {1} role temporarily. The role expires {2}",
"user_afk": "User {0} is AFK.", "user_afk": "User {0} is AFK.",
"notify_on": "Notification message will be sent on this channel when {0} event triggers.", "notify_on": "Notification message will be sent in {0} channel when {1} event triggers.",
"notify_off": "Notification message will no longer be sent when {0} event triggers." "notify_off": "Notification message will no longer be sent when {0} event triggers.",
"notify_none": "No notifications on this page.",
"notify_msg_not_set": "Notification message is not set for this event.",
"notify_list": "Notify List",
"notify_type": "Type",
"notify_msg": "Notify Message",
"notify_available": "List of available notify events",
"notify_desc_levelup": "Triggers when a user levels up on this server.",
"notify_desc_protection": "Triggers when antialt, antispam or antiraid is triggered.",
"notify_desc_addrolerew": "Triggers when a user gets a role as a reward for reaching a level (xprew).",
"notify_desc_removerolerew": "Triggers when a user loses a role as a reward for reaching a level (xprew).",
"notify_desc_not_found": "No description found for this notify event. Please report this."
} }