diff --git a/src/EllieBot/Db/Models/xp/ExcludedItem.cs b/src/EllieBot/Db/Models/xp/ExcludedItem.cs deleted file mode 100644 index a0b8b00..0000000 --- a/src/EllieBot/Db/Models/xp/ExcludedItem.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace EllieBot.Db.Models; - -public class ExcludedItem : DbEntity -{ - public int? XpSettingsId { get; set; } - public ulong ItemId { get; set; } - public ExcludedItemType ItemType { get; set; } - - public override int GetHashCode() - => ItemId.GetHashCode() ^ ItemType.GetHashCode(); - - public override bool Equals(object? obj) - => obj is ExcludedItem ei && ei.ItemId == ItemId && ei.ItemType == ItemType; -} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/xp/ExcludedItemType.cs b/src/EllieBot/Db/Models/xp/ExcludedItemType.cs index e6b1e6a..f001061 100644 --- a/src/EllieBot/Db/Models/xp/ExcludedItemType.cs +++ b/src/EllieBot/Db/Models/xp/ExcludedItemType.cs @@ -1,3 +1,7 @@ namespace EllieBot.Db.Models; -public enum ExcludedItemType { Channel, Role } \ No newline at end of file +public enum XpExcludedItemType +{ + User, + Role +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/xp/XpExcludedItem.cs b/src/EllieBot/Db/Models/xp/XpExcludedItem.cs new file mode 100644 index 0000000..d4c93cd --- /dev/null +++ b/src/EllieBot/Db/Models/xp/XpExcludedItem.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace EllieBot.Db.Models; + +public class XpExcludedItem +{ + [Key] + public int Id { get; set; } + + public ulong GuildId { get; set; } + + public XpExcludedItemType ItemType { get; set; } + public ulong ItemId { get; set; } +} + +public sealed class XpExclusionEntityConfig : IEntityTypeConfiguration<XpExcludedItem> +{ + public void Configure(EntityTypeBuilder<XpExcludedItem> builder) + { + builder.HasIndex(x => x.GuildId); + + builder.HasAlternateKey(x => new + { + x.GuildId, + x.ItemType, + x.ItemId + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Migrations/PostgreSql/20250318221943_xpexclusion.sql b/src/EllieBot/Migrations/PostgreSql/20250318221943_xpexclusion.sql new file mode 100644 index 0000000..a0714f8 --- /dev/null +++ b/src/EllieBot/Migrations/PostgreSql/20250318221943_xpexclusion.sql @@ -0,0 +1,17 @@ +START TRANSACTION; +CREATE TABLE xpexcludeditem ( + id integer GENERATED BY DEFAULT AS IDENTITY, + guildid numeric(20,0) NOT NULL, + itemtype integer NOT NULL, + itemid numeric(20,0) NOT NULL, + CONSTRAINT pk_xpexcludeditem PRIMARY KEY (id), + CONSTRAINT ak_xpexcludeditem_guildid_itemtype_itemid UNIQUE (guildid, itemtype, itemid) +); + +CREATE INDEX ix_xpexcludeditem_guildid ON xpexcludeditem (guildid); + +INSERT INTO "__EFMigrationsHistory" (migrationid, productversion) +VALUES ('20250318221943_xpexclusion', '9.0.1'); + +COMMIT; + diff --git a/src/EllieBot/Migrations/PostgreSql/20250317063309_init.Designer.cs b/src/EllieBot/Migrations/PostgreSql/20250318222207_init.Designer.cs similarity index 99% rename from src/EllieBot/Migrations/PostgreSql/20250317063309_init.Designer.cs rename to src/EllieBot/Migrations/PostgreSql/20250318222207_init.Designer.cs index b95382f..db697cd 100644 --- a/src/EllieBot/Migrations/PostgreSql/20250317063309_init.Designer.cs +++ b/src/EllieBot/Migrations/PostgreSql/20250318222207_init.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace EllieBot.Migrations.PostgreSql { [DbContext(typeof(PostgreSqlContext))] - [Migration("20250317063309_init")] + [Migration("20250318222207_init")] partial class init { /// <inheritdoc /> @@ -3263,6 +3263,39 @@ namespace EllieBot.Migrations.PostgreSql b.ToTable("xpcurrencyreward", (string)null); }); + modelBuilder.Entity("EllieBot.Db.Models.XpExcludedItem", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<decimal>("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property<decimal>("ItemId") + .HasColumnType("numeric(20,0)") + .HasColumnName("itemid"); + + b.Property<int>("ItemType") + .HasColumnType("integer") + .HasColumnName("itemtype"); + + b.HasKey("Id") + .HasName("pk_xpexcludeditem"); + + b.HasAlternateKey("GuildId", "ItemType", "ItemId") + .HasName("ak_xpexcludeditem_guildid_itemtype_itemid"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_xpexcludeditem_guildid"); + + b.ToTable("xpexcludeditem", (string)null); + }); + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => { b.Property<int>("Id") diff --git a/src/EllieBot/Migrations/PostgreSql/20250317063309_init.cs b/src/EllieBot/Migrations/PostgreSql/20250318222207_init.cs similarity index 99% rename from src/EllieBot/Migrations/PostgreSql/20250317063309_init.cs rename to src/EllieBot/Migrations/PostgreSql/20250318222207_init.cs index d1ca439..5278da3 100644 --- a/src/EllieBot/Migrations/PostgreSql/20250317063309_init.cs +++ b/src/EllieBot/Migrations/PostgreSql/20250318222207_init.cs @@ -1179,6 +1179,22 @@ namespace EllieBot.Migrations.PostgreSql table.PrimaryKey("pk_warnings", x => x.id); }); + migrationBuilder.CreateTable( + name: "xpexcludeditem", + columns: table => new + { + id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false), + itemtype = table.Column<int>(type: "integer", nullable: false), + itemid = table.Column<decimal>(type: "numeric(20,0)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_xpexcludeditem", x => x.id); + table.UniqueConstraint("ak_xpexcludeditem_guildid_itemtype_itemid", x => new { x.guildid, x.itemtype, x.itemid }); + }); + migrationBuilder.CreateTable( name: "xpsettings", columns: table => new @@ -2280,6 +2296,11 @@ namespace EllieBot.Migrations.PostgreSql table: "xpcurrencyreward", column: "xpsettingsid"); + migrationBuilder.CreateIndex( + name: "ix_xpexcludeditem_guildid", + table: "xpexcludeditem", + column: "guildid"); + migrationBuilder.CreateIndex( name: "ix_xprolereward_xpsettingsid_level", table: "xprolereward", @@ -2574,6 +2595,9 @@ namespace EllieBot.Migrations.PostgreSql migrationBuilder.DropTable( name: "xpcurrencyreward"); + migrationBuilder.DropTable( + name: "xpexcludeditem"); + migrationBuilder.DropTable( name: "xprolereward"); diff --git a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs index 36d701a..e9a47ca 100644 --- a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs +++ b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs @@ -3260,6 +3260,39 @@ namespace EllieBot.Migrations.PostgreSql b.ToTable("xpcurrencyreward", (string)null); }); + modelBuilder.Entity("EllieBot.Db.Models.XpExcludedItem", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<decimal>("GuildId") + .HasColumnType("numeric(20,0)") + .HasColumnName("guildid"); + + b.Property<decimal>("ItemId") + .HasColumnType("numeric(20,0)") + .HasColumnName("itemid"); + + b.Property<int>("ItemType") + .HasColumnType("integer") + .HasColumnName("itemtype"); + + b.HasKey("Id") + .HasName("pk_xpexcludeditem"); + + b.HasAlternateKey("GuildId", "ItemType", "ItemId") + .HasName("ak_xpexcludeditem_guildid_itemtype_itemid"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_xpexcludeditem_guildid"); + + b.ToTable("xpexcludeditem", (string)null); + }); + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => { b.Property<int>("Id") diff --git a/src/EllieBot/Migrations/Sqlite/20250318221922_xpexclusion.sql b/src/EllieBot/Migrations/Sqlite/20250318221922_xpexclusion.sql new file mode 100644 index 0000000..19fee90 --- /dev/null +++ b/src/EllieBot/Migrations/Sqlite/20250318221922_xpexclusion.sql @@ -0,0 +1,16 @@ +BEGIN TRANSACTION; +CREATE TABLE "XpExcludedItem" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_XpExcludedItem" PRIMARY KEY AUTOINCREMENT, + "GuildId" INTEGER NOT NULL, + "ItemType" INTEGER NOT NULL, + "ItemId" INTEGER NOT NULL, + CONSTRAINT "AK_XpExcludedItem_GuildId_ItemType_ItemId" UNIQUE ("GuildId", "ItemType", "ItemId") +); + +CREATE INDEX "IX_XpExcludedItem_GuildId" ON "XpExcludedItem" ("GuildId"); + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20250318221922_xpexclusion', '9.0.1'); + +COMMIT; + diff --git a/src/EllieBot/Migrations/Sqlite/20250317063300_init.Designer.cs b/src/EllieBot/Migrations/Sqlite/20250318222152_init.Designer.cs similarity index 99% rename from src/EllieBot/Migrations/Sqlite/20250317063300_init.Designer.cs rename to src/EllieBot/Migrations/Sqlite/20250318222152_init.Designer.cs index 93429ce..969d466 100644 --- a/src/EllieBot/Migrations/Sqlite/20250317063300_init.Designer.cs +++ b/src/EllieBot/Migrations/Sqlite/20250318222152_init.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace EllieBot.Migrations.Sqlite { [DbContext(typeof(SqliteContext))] - [Migration("20250317063300_init")] + [Migration("20250318222152_init")] partial class init { /// <inheritdoc /> @@ -2428,6 +2428,30 @@ namespace EllieBot.Migrations.Sqlite b.ToTable("XpCurrencyReward"); }); + modelBuilder.Entity("EllieBot.Db.Models.XpExcludedItem", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<ulong>("GuildId") + .HasColumnType("INTEGER"); + + b.Property<ulong>("ItemId") + .HasColumnType("INTEGER"); + + b.Property<int>("ItemType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasAlternateKey("GuildId", "ItemType", "ItemId"); + + b.HasIndex("GuildId"); + + b.ToTable("XpExcludedItem"); + }); + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => { b.Property<int>("Id") diff --git a/src/EllieBot/Migrations/Sqlite/20250317063300_init.cs b/src/EllieBot/Migrations/Sqlite/20250318222152_init.cs similarity index 99% rename from src/EllieBot/Migrations/Sqlite/20250317063300_init.cs rename to src/EllieBot/Migrations/Sqlite/20250318222152_init.cs index 73c5d84..429b5fb 100644 --- a/src/EllieBot/Migrations/Sqlite/20250317063300_init.cs +++ b/src/EllieBot/Migrations/Sqlite/20250318222152_init.cs @@ -1181,6 +1181,22 @@ namespace EllieBot.Migrations.Sqlite table.PrimaryKey("PK_Warnings", x => x.Id); }); + migrationBuilder.CreateTable( + name: "XpExcludedItem", + columns: table => new + { + Id = table.Column<int>(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GuildId = table.Column<ulong>(type: "INTEGER", nullable: false), + ItemType = table.Column<int>(type: "INTEGER", nullable: false), + ItemId = table.Column<ulong>(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_XpExcludedItem", x => x.Id); + table.UniqueConstraint("AK_XpExcludedItem_GuildId_ItemType_ItemId", x => new { x.GuildId, x.ItemType, x.ItemId }); + }); + migrationBuilder.CreateTable( name: "XpSettings", columns: table => new @@ -2282,6 +2298,11 @@ namespace EllieBot.Migrations.Sqlite table: "XpCurrencyReward", column: "XpSettingsId"); + migrationBuilder.CreateIndex( + name: "IX_XpExcludedItem_GuildId", + table: "XpExcludedItem", + column: "GuildId"); + migrationBuilder.CreateIndex( name: "IX_XpRoleReward_XpSettingsId_Level", table: "XpRoleReward", @@ -2576,6 +2597,9 @@ namespace EllieBot.Migrations.Sqlite migrationBuilder.DropTable( name: "XpCurrencyReward"); + migrationBuilder.DropTable( + name: "XpExcludedItem"); + migrationBuilder.DropTable( name: "XpRoleReward"); diff --git a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs index 0792778..6ef86fe 100644 --- a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs +++ b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs @@ -2425,6 +2425,30 @@ namespace EllieBot.Migrations.Sqlite b.ToTable("XpCurrencyReward"); }); + modelBuilder.Entity("EllieBot.Db.Models.XpExcludedItem", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<ulong>("GuildId") + .HasColumnType("INTEGER"); + + b.Property<ulong>("ItemId") + .HasColumnType("INTEGER"); + + b.Property<int>("ItemType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasAlternateKey("GuildId", "ItemType", "ItemId"); + + b.HasIndex("GuildId"); + + b.ToTable("XpExcludedItem"); + }); + modelBuilder.Entity("EllieBot.Db.Models.XpRoleReward", b => { b.Property<int>("Id") diff --git a/src/EllieBot/Modules/Xp/XpExclusion/XpExclusionCommands.cs b/src/EllieBot/Modules/Xp/XpExclusion/XpExclusionCommands.cs new file mode 100644 index 0000000..43a4624 --- /dev/null +++ b/src/EllieBot/Modules/Xp/XpExclusion/XpExclusionCommands.cs @@ -0,0 +1,84 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Xp; + +public partial class Xp +{ + [RequireUserPermission(GuildPermission.Administrator)] + public class XpExclusionCommands : EllieModule<XpExclusionService> + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpExclusion() + { + var exclusions = await _service.GetExclusionsAsync(ctx.Guild.Id); + + if (!exclusions.Any()) + { + await Response().Pending(strs.xp_exclusion_none).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(exclusions.OrderBy(x => x.ItemType).ToList()) + .PageSize(10) + .Page((items, _) => + { + var eb = CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.xp_exclusion_title)); + + foreach (var item in items) + { + var itemType = item.ItemType; + var mention = GetMention(itemType, item.ItemId); + + eb.AddField(itemType.ToString(), mention); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpExclude([Leftover] IRole role) + => await XpExclude(XpExcludedItemType.Role, role.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpExclude([Leftover] IUser user) + => await XpExclude(XpExcludedItemType.User, user.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpExclude(XpExcludedItemType type, ulong itemId) + { + var isExcluded = await _service.ToggleExclusionAsync(ctx.Guild.Id, type, itemId); + + if (isExcluded) + await Response() + .Confirm(strs.xp_exclude_added(type.ToString(), GetMention(type, itemId))) + .SendAsync(); + else + await Response() + .Confirm(strs.xp_exclude_removed(type.ToString(), GetMention(type, itemId))) + .SendAsync(); + } + + private string GetMention(XpExcludedItemType itemType, ulong itemId) + => itemType switch + { + XpExcludedItemType.Role => ctx.Guild.GetRole(itemId)?.ToString() ?? itemId.ToString(), + XpExcludedItemType.User => (ctx.Guild as SocketGuild)?.GetUser(itemId)?.ToString() ?? + itemId.ToString(), + _ => itemId.ToString() + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/XpExclusion/XpExclusionService.cs b/src/EllieBot/Modules/Xp/XpExclusion/XpExclusionService.cs new file mode 100644 index 0000000..e12375c --- /dev/null +++ b/src/EllieBot/Modules/Xp/XpExclusion/XpExclusionService.cs @@ -0,0 +1,99 @@ +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using LinqToDB; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Modules.Xp; + +public class XpExclusionService(DbService db, ShardData shardData) : IReadyExecutor, IEService +{ + private ConcurrentHashSet<(ulong GuildId, XpExcludedItemType ItemType, ulong ItemId)> _exclusions = new(); + + public async Task OnReadyAsync() + { + await using var uow = db.GetDbContext(); + _exclusions = await uow.GetTable<XpExcludedItem>() + .Where(x => Queries.GuildOnShard(x.GuildId, shardData.TotalShards, shardData.ShardId)) + .ToListAsyncLinqToDB() + .Fmap(x => x + .Select(x => (x.GuildId, x.ItemType, x.ItemId)) + .ToHashSet() + .ToConcurrentSet()); + } + + /// <summary> + /// Toggles exclusion for the specified item. If the item was excluded, it will be included + /// and vice versa. + /// </summary> + /// <param name="guildId">ID of the guild</param> + /// <param name="itemType">Type of the item to toggle exclusion for</param> + /// <param name="itemId">ID of the item to toggle exclusion for</param> + /// <returns>True if the item is now excluded, false if it's no longer excluded</returns> + public async Task<bool> ToggleExclusionAsync(ulong guildId, XpExcludedItemType itemType, ulong itemId) + { + var key = (guildId, itemType, itemId); + var isExcluded = false; + + await using var uow = db.GetDbContext(); + if (_exclusions.Contains(key)) + { + isExcluded = false; + // item exists, remove it + await uow.GetTable<XpExcludedItem>() + .Where(x => x.GuildId == guildId + && x.ItemType == itemType + && x.ItemId == itemId) + .DeleteAsync(); + + _exclusions.TryRemove(key); + } + else + { + isExcluded = true; + // item doesn't exist, add it + await uow.GetTable<XpExcludedItem>() + .InsertOrUpdateAsync(() => new XpExcludedItem + { + GuildId = guildId, + ItemType = itemType, + ItemId = itemId + }, + _ => new(), + () => new XpExcludedItem + { + GuildId = guildId, + ItemType = itemType, + ItemId = itemId + }); + + _exclusions.Add(key); + } + + return isExcluded; + } + + /// <summary> + /// Gets a list of all excluded items for a guild. + /// </summary> + /// <param name="guildId">ID of the guild</param> + /// <returns>List of excluded items in the guild</returns> + public async Task<IReadOnlyList<XpExcludedItem>> GetExclusionsAsync(ulong guildId) + { + await using var uow = db.GetDbContext(); + return await uow.GetTable<XpExcludedItem>() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .ToListAsyncLinqToDB(); + } + + /// <summary> + /// Checks if the specified item is excluded from XP gain. + /// </summary> + /// <param name="guildId">ID of the guild</param> + /// <param name="itemType">Type of the item</param> + /// <param name="itemId">ID of the item</param> + /// <returns>True if the item is excluded, otherwise false</returns> + public bool IsExcluded(ulong guildId, XpExcludedItemType itemType, ulong itemId) + => _exclusions.Contains((guildId, itemType, itemId)); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/XpRate/GuildConfigXpService.cs b/src/EllieBot/Modules/Xp/XpRate/GuildConfigXpService.cs index 9b08527..495c7d5 100644 --- a/src/EllieBot/Modules/Xp/XpRate/GuildConfigXpService.cs +++ b/src/EllieBot/Modules/Xp/XpRate/GuildConfigXpService.cs @@ -9,7 +9,7 @@ namespace EllieBot.Modules.Xp; using GuildXpRates = (IReadOnlyList<GuildXpConfig> GuildRates, IReadOnlyList<ChannelXpConfig> ChannelRates); -public class GuildConfigXpService(DbService db, ShardData shardData, XpConfigService xcs) : IReadyExecutor, IEService +public class XpRateService(DbService db, ShardData shardData, XpConfigService xcs) : IReadyExecutor, IEService { private ConcurrentDictionary<(XpRateType RateType, ulong GuildId), XpRate> _guildRates = new(); private ConcurrentDictionary<ulong, ConcurrentDictionary<(XpRateType, ulong), XpRate>> _channelRates = new(); diff --git a/src/EllieBot/Modules/Xp/XpRate/XpRateCommands.cs b/src/EllieBot/Modules/Xp/XpRate/XpRateCommands.cs index 4cb471a..7511cbd 100644 --- a/src/EllieBot/Modules/Xp/XpRate/XpRateCommands.cs +++ b/src/EllieBot/Modules/Xp/XpRate/XpRateCommands.cs @@ -3,7 +3,7 @@ namespace EllieBot.Modules.Xp; public partial class Xp { [RequireUserPermission(GuildPermission.ManageGuild)] - public class XpRateCommands : EllieModule<GuildConfigXpService> + public class XpRateCommands : EllieModule<XpRateService> { [Cmd] [RequireContext(ContextType.Guild)] diff --git a/src/EllieBot/Modules/Xp/XpService.cs b/src/EllieBot/Modules/Xp/XpService.cs index a3c3fcc..b371299 100644 --- a/src/EllieBot/Modules/Xp/XpService.cs +++ b/src/EllieBot/Modules/Xp/XpService.cs @@ -40,7 +40,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand private readonly INotifySubscriber _notifySub; private readonly IMemoryCache _memCache; private readonly XpTemplateService _templateService; - private readonly GuildConfigXpService _xpRateService; + private readonly XpRateService _xpRateRateService; + private readonly XpExclusionService _xpExcl; private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 100); @@ -60,7 +61,9 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand IMemoryCache memCache, ShardData shardData, XpTemplateService templateService, - GuildConfigXpService xpRateService) + XpRateService xpRateRateService, + XpExclusionService xpExcl + ) { _db = db; _images = images; @@ -72,7 +75,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand _notifySub = notifySub; _memCache = memCache; _templateService = templateService; - _xpRateService = xpRateService; + _xpRateRateService = xpRateRateService; + _xpExcl = xpExcl; _client = client; _ps = ps; _c = c; @@ -143,7 +147,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand if (!IsVoiceChannelActive(vc)) continue; - var rate = _xpRateService.GetXpRate(XpRateType.Voice, g.Id, vc.Id); + var rate = _xpRateRateService.GetXpRate(XpRateType.Voice, g.Id, vc.Id); if (rate.IsExcluded()) continue; @@ -153,6 +157,9 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand if (!UserParticipatingInVoiceChannel(u)) continue; + if (IsUserExcluded(g, u)) + continue; + if (oldBatch.Contains(u)) { validUsers.Add(new(u, rate.Amount, vc.Id)); @@ -509,15 +516,18 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand _ = Task.Run(async () => { + if (IsUserExcluded(guild, user)) + return; + var isImage = arg.Attachments.Any(a => a.Height >= 16 && a.Width >= 16); var isText = arg.Content.Contains(' ') || arg.Content.Length >= 5; - var textRate = _xpRateService.GetXpRate(XpRateType.Text, guild.Id, gc.Id); + var textRate = _xpRateRateService.GetXpRate(XpRateType.Text, guild.Id, gc.Id); XpRate rate; if (isImage) { - var imageRate = _xpRateService.GetXpRate(XpRateType.Image, guild.Id, gc.Id); + var imageRate = _xpRateRateService.GetXpRate(XpRateType.Image, guild.Id, gc.Id); if (imageRate.IsExcluded()) return; @@ -544,6 +554,20 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand return Task.CompletedTask; } + private bool IsUserExcluded(IGuild guild, SocketGuildUser user) + { + if (_xpExcl.IsExcluded(guild.Id, XpExcludedItemType.User, user.Id)) + return true; + + foreach (var role in user.Roles) + { + if (_xpExcl.IsExcluded(guild.Id, XpExcludedItemType.Role, role.Id)) + return true; + } + + return false; + } + public async Task<int> AddXpToUsersAsync(ulong guildId, long amount, params ulong[] userIds) { await using var ctx = _db.GetDbContext(); @@ -614,49 +638,54 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand } var avatarUrl = stats.User.RealAvatarUrl(); - byte[] avatarImageData = null; - - if (avatarUrl is not null) + var avatarFetchTask = Task.Run(async () => { - var result = await _c.GetImageDataAsync(avatarUrl); - if (!result.TryPickT0(out avatarImageData, out _)) + try { - using (var http = _httpFactory.CreateClient()) - { - var avatarData = await http.GetByteArrayAsync(avatarUrl); - using (var tempDraw = Image.Load<Rgba32>(avatarData)) - { - tempDraw.Mutate(x => x - .Resize(template.User.Icon.Size.X, template.User.Icon.Size.Y) - .ApplyRoundedCorners(Math.Max(template.User.Icon.Size.X, - template.User.Icon.Size.Y) - / 2.0f)); - await using (var stream = await tempDraw.ToStreamAsync()) - { - avatarImageData = stream.ToArray(); - } - } - } + if (avatarUrl is null) + return null; - await _c.SetImageDataAsync(avatarUrl, avatarImageData); + var result = await _c.GetImageDataAsync(avatarUrl); + if (result.TryPickT0(out var imgData, out _)) + return imgData; + + using var http = _httpFactory.CreateClient(); + + var avatarData = await http.GetByteArrayAsync(avatarUrl); + using var tempDraw = Image.Load<Rgba32>(avatarData); + + tempDraw.Mutate(x => x + .Resize(template.User.Icon.Size.X, template.User.Icon.Size.Y) + .ApplyRoundedCorners(Math.Max(template.User.Icon.Size.X, + template.User.Icon.Size.Y) + / 2.0f)); + await using var stream = await tempDraw.ToStreamAsync(); + var data = stream.ToArray(); + await _c.SetImageDataAsync(avatarUrl, data); + return data; } - } + catch (Exception) + { + return null; + } + }); using var img = Image.Load<Rgba32>(bgBytes); - if (template.User.Name.Show) + + img.Mutate(x => { - var fontSize = (int)(template.User.Name.FontSize * 0.9); - var username = stats.User.ToString(); - var usernameFont = _fonts.NotoSans.CreateFont(fontSize, FontStyle.Bold); - - var size = TextMeasurer.MeasureSize($"@{username}", new(usernameFont)); - var scale = 400f / size.Width; - if (scale < 1) - usernameFont = _fonts.NotoSans.CreateFont(template.User.Name.FontSize * scale, FontStyle.Bold); - - img.Mutate(x => + if (template.User.Name.Show) { + var fontSize = (int)(template.User.Name.FontSize * 0.9); + var username = stats.User.ToString(); + var usernameFont = _fonts.NotoSans.CreateFont(fontSize, FontStyle.Bold); + + var size = TextMeasurer.MeasureSize($"@{username}", new(usernameFont)); + var scale = 400f / size.Width; + if (scale < 1) + usernameFont = _fonts.NotoSans.CreateFont(template.User.Name.FontSize * scale, FontStyle.Bold); + x.DrawText(new RichTextOptions(usernameFont) { HorizontalAlignment = HorizontalAlignment.Left, @@ -666,126 +695,129 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand }, "@" + username, Brushes.Solid(template.User.Name.Color)); + } - //club name + //club name - if (template.Club.Name.Show) + if (template.Club.Name.Show) + { + var clubName = stats.User.Club?.ToString() ?? "-"; + + var clubFont = _fonts.NotoSans.CreateFont(template.Club.Name.FontSize, FontStyle.Regular); + + x.DrawText(new RichTextOptions(clubFont) { - var clubName = stats.User.Club?.ToString() ?? "-"; + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Top, + FallbackFontFamilies = _fonts.FallBackFonts, + Origin = new(template.Club.Name.Pos.X + 50, template.Club.Name.Pos.Y - 8) + }, + clubName, + Brushes.Solid(template.Club.Name.Color)); + } - var clubFont = _fonts.NotoSans.CreateFont(template.Club.Name.FontSize, FontStyle.Regular); + Font GetTruncatedFont( + FontFamily fontFamily, + int fontSize, + FontStyle style, + string text, + int maxSize) + { + var font = fontFamily.CreateFont(fontSize, style); + var size = TextMeasurer.MeasureSize(text, new(font)); + var scale = maxSize / size.Width; + if (scale < 1) + font = fontFamily.CreateFont(fontSize * scale, style); - x.DrawText(new RichTextOptions(clubFont) + return font; + } + + + if (template.User.Level.Show) + { + var guildLevelFont = GetTruncatedFont( + _fonts.NotoSans, + template.User.Level.FontSize, + FontStyle.Bold, + stats.Guild.Level.ToString(), + 33); + + + x.DrawText(stats.Guild.Level.ToString(), + guildLevelFont, + template.User.Level.Color, + new(template.User.Level.Pos.X, template.User.Level.Pos.Y)); + } + + + var guild = stats.Guild; + + //xp bar + if (template.User.Xp.Bar.Show) + { + var xpPercent = guild.LevelXp / (float)guild.RequiredXp; + DrawXpBar(xpPercent, template.User.Xp.Bar.Guild, img); + } + + if (template.User.Xp.Guild.Show) + { + x.DrawText( + new RichTextOptions(_fonts.NotoSans.CreateFont(template.User.Xp.Guild.FontSize, + FontStyle.Bold)) { - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Top, - FallbackFontFamilies = _fonts.FallBackFonts, - Origin = new(template.Club.Name.Pos.X + 50, template.Club.Name.Pos.Y - 8) + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Origin = new(template.User.Xp.Guild.Pos.X, template.User.Xp.Guild.Pos.Y) }, - clubName, - Brushes.Solid(template.Club.Name.Color)); - } + $"{guild.LevelXp}/{guild.RequiredXp}", + Brushes.Solid(template.User.Xp.Guild.Color)); + } - Font GetTruncatedFont( - FontFamily fontFamily, - int fontSize, - FontStyle style, - string text, - int maxSize) - { - var font = fontFamily.CreateFont(fontSize, style); - var size = TextMeasurer.MeasureSize(text, new(font)); - var scale = maxSize / size.Width; - if (scale < 1) - font = fontFamily.CreateFont(fontSize * scale, style); + var rankPen = new SolidPen(Color.White, 1); + //ranking - return font; - } + if (template.User.Rank.Show) + { + var guildRankStr = stats.GuildRanking.ToString(); + var guildRankFont = GetTruncatedFont( + _fonts.NotoSans, + template.User.Rank.FontSize, + FontStyle.Bold, + guildRankStr, + 22); - if (template.User.Level.Show) - { - var guildLevelFont = GetTruncatedFont( - _fonts.NotoSans, - template.User.Level.FontSize, - FontStyle.Bold, - stats.Guild.Level.ToString(), - 33); - - - x.DrawText(stats.Guild.Level.ToString(), - guildLevelFont, - template.User.Level.Color, - new(template.User.Level.Pos.X, template.User.Level.Pos.Y)); - } - - - var guild = stats.Guild; - - //xp bar - if (template.User.Xp.Bar.Show) - { - var xpPercent = guild.LevelXp / (float)guild.RequiredXp; - DrawXpBar(xpPercent, template.User.Xp.Bar.Guild, img); - } - - if (template.User.Xp.Guild.Show) - { - x.DrawText( - new RichTextOptions(_fonts.NotoSans.CreateFont(template.User.Xp.Guild.FontSize, - FontStyle.Bold)) - { - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - Origin = new(template.User.Xp.Guild.Pos.X, template.User.Xp.Guild.Pos.Y) - }, - $"{guild.LevelXp}/{guild.RequiredXp}", - Brushes.Solid(template.User.Xp.Guild.Color)); - } - - var rankPen = new SolidPen(Color.White, 1); - //ranking - - if (template.User.Rank.Show) - { - var guildRankStr = stats.GuildRanking.ToString(); - - var guildRankFont = GetTruncatedFont( - _fonts.NotoSans, - template.User.Rank.FontSize, - FontStyle.Bold, - guildRankStr, - 22); - - x.DrawText( - new RichTextOptions(guildRankFont) - { - Origin = new(template.User.Rank.Pos.X, template.User.Rank.Pos.Y) - }, - guildRankStr, - Brushes.Solid(template.User.Rank.Color), - rankPen - ); - } - - if (template.User.Icon.Show) - { - try + x.DrawText( + new RichTextOptions(guildRankFont) { - using var toDraw = Image.Load(avatarImageData); - if (toDraw.Size != new Size(template.User.Icon.Size.X, template.User.Icon.Size.Y)) - toDraw.Mutate(x - => x.Resize(template.User.Icon.Size.X, template.User.Icon.Size.Y)); + Origin = new(template.User.Rank.Pos.X, template.User.Rank.Pos.Y) + }, + guildRankStr, + Brushes.Solid(template.User.Rank.Color), + rankPen + ); + } + }); - x.DrawImage(toDraw, - new Point(template.User.Icon.Pos.X, template.User.Icon.Pos.Y), - 1); - } - catch (Exception ex) - { - Log.Warning(ex, "Error drawing avatar image"); - } + if (template.User.Icon.Show) + { + var avImageData = await avatarFetchTask; + img.Mutate(mut => + { + try + { + using var toDraw = Image.Load(avImageData); + if (toDraw.Size != new Size(template.User.Icon.Size.X, template.User.Icon.Size.Y)) + toDraw.Mutate(x => x.Resize(template.User.Icon.Size.X, template.User.Icon.Size.Y)); + + mut.DrawImage(toDraw, + new Point(template.User.Icon.Pos.X, template.User.Icon.Pos.Y), + 1); + } + catch (Exception ex) + { + Log.Warning(ex, "Error drawing avatar image"); } }); } diff --git a/src/EllieBot/strings/aliases.yml b/src/EllieBot/strings/aliases.yml index 1ba0dc7..1e39a10 100644 --- a/src/EllieBot/strings/aliases.yml +++ b/src/EllieBot/strings/aliases.yml @@ -1626,4 +1626,10 @@ scheduledelete: scheduleadd: - scheduleadd - scha - - schadd \ No newline at end of file + - schadd +xpexclusion: + - xpexclusion + - xpexl +xpexclude: + - xpexclude + - xpex \ No newline at end of file diff --git a/src/EllieBot/strings/commands/commands.en-US.yml b/src/EllieBot/strings/commands/commands.en-US.yml index 3c17d20..a920bb4 100644 --- a/src/EllieBot/strings/commands/commands.en-US.yml +++ b/src/EllieBot/strings/commands/commands.en-US.yml @@ -1854,7 +1854,7 @@ playlistload: - 5 params: - id: - desc: "The id of the playlist to be loaded." + desc: "The id of the playlist to be loaded." playlistsave: desc: Saves a playlist under a certain name. Playlist name must be no longer than 20 characters and must not contain dashes. ex: @@ -5100,4 +5100,25 @@ scheduleadd: - time: desc: "How long it takes for the command to execute. Example: 1h30m = 1 hour and 30 minutes" - command: - desc: "Command that will be executed after the specified time has elapsed" \ No newline at end of file + desc: "Command that will be executed after the specified time has elapsed" +xpexclusion: + desc: |- + Shows a list of all XP exclusions in the server. + ex: + - '' + params: + - { } +xpexclude: + desc: |- + Toggles XP gain exclusion for a specified item. + Item types can be Role or User. + ex: + - '@CoolRole' + - '@User' + - 'role 123123123' + - 'user 123123123' + params: + - type: + desc: "Type of the item to exclude: role or user" + itemId: + desc: "ID or mention of the item to exclude." \ No newline at end of file diff --git a/src/EllieBot/strings/responses/responses.en-US.json b/src/EllieBot/strings/responses/responses.en-US.json index e28be28..1427c2a 100644 --- a/src/EllieBot/strings/responses/responses.en-US.json +++ b/src/EllieBot/strings/responses/responses.en-US.json @@ -1217,5 +1217,9 @@ "schedule_command": "Command", "schedule_when": "Executes At", "schedule_add_success": "Scheduled command successfully added.", - "schedule_add_error": "You already have 5 scheduled commands. Please delete some before adding more." + "schedule_add_error": "You already have 5 scheduled commands. Please delete some before adding more.", + "xp_exclusion_none": "There are no exclusions set for this server.", + "xp_exclusion_title": "XP Exclusions", + "xp_exclude_added": "{0}: {1} has been excluded from the XP system.", + "xp_exclude_removed": "{0}: {1} is no longer excluded from the XP system." } \ No newline at end of file