Compare commits

...

2 commits

Author SHA1 Message Date
11b3705939
Fixed xprate presentation and reworked it internally to support voice, text and image xp 2025-03-01 13:56:17 +13:00
5e95abadc8
Added .xprate and .xpratereset which let users modify xp rates on their server, as there is no more global xp
.xpexcl removed as you can exclude xp gain on the server or channel by setting any value in xprate to 0
2025-03-01 13:04:20 +13:00
29 changed files with 1049 additions and 645 deletions

View file

@ -121,7 +121,6 @@ public static class GuildConfigExtensions
.InsertWithOutputAsync(() => new()
{
GuildId = guildId,
ServerExcluded = false,
});
return srs;

View file

@ -1,4 +1,3 @@
#nullable disable
namespace EllieBot.Db.Models;
public class UserXpStats : DbEntity

View file

@ -1,14 +1,9 @@
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace EllieBot.Db.Models;
public class XpSettings : DbEntity
{
public ulong GuildId { get; set; }
public bool ServerExcluded { get; set; }
public HashSet<ExcludedItem> ExclusionList { get; set; } = new();
public HashSet<XpRoleReward> RoleRewards { get; set; } = new();
public HashSet<XpCurrencyReward> CurrencyRewards { get; set; } = new();

View file

@ -15,8 +15,5 @@ public class XpSettingsEntityConfiguration : IEntityTypeConfiguration<XpSettings
builder.HasMany(x => x.RoleRewards)
.WithOne();
builder.HasMany(x => x.ExclusionList)
.WithOne();
}
}

View file

@ -0,0 +1,27 @@
START TRANSACTION;
ALTER TABLE userfishstats ADD bait integer;
ALTER TABLE userfishstats ADD pole integer;
CREATE TABLE channelxpconfig (
id integer GENERATED BY DEFAULT AS IDENTITY,
guildid numeric(20,0) NOT NULL,
channelid numeric(20,0) NOT NULL,
xpamount integer NOT NULL,
cooldown real NOT NULL,
CONSTRAINT pk_channelxpconfig PRIMARY KEY (id),
CONSTRAINT ak_channelxpconfig_guildid_channelid UNIQUE (guildid, channelid)
);
CREATE TABLE guildxpconfig (
guildid numeric(20,0) NOT NULL,
xpamount integer NOT NULL,
cooldown integer NOT NULL,
xptemplateurl text,
CONSTRAINT pk_guildxpconfig PRIMARY KEY (guildid)
);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250225212147_xp-excl-xp-rate', '9.0.1');
COMMIT;

View file

@ -0,0 +1,31 @@
START TRANSACTION;
DROP TABLE excludeditem;
ALTER TABLE guildxpconfig DROP CONSTRAINT pk_guildxpconfig;
ALTER TABLE channelxpconfig DROP CONSTRAINT ak_channelxpconfig_guildid_channelid;
ALTER TABLE xpsettings DROP COLUMN serverexcluded;
ALTER TABLE guildxpconfig ALTER COLUMN xpamount TYPE bigint;
ALTER TABLE guildxpconfig ALTER COLUMN cooldown TYPE real;
ALTER TABLE guildxpconfig ADD id integer GENERATED BY DEFAULT AS IDENTITY;
ALTER TABLE guildxpconfig ADD ratetype integer NOT NULL DEFAULT 0;
ALTER TABLE channelxpconfig ALTER COLUMN xpamount TYPE bigint;
ALTER TABLE channelxpconfig ADD ratetype integer NOT NULL DEFAULT 0;
ALTER TABLE guildxpconfig ADD CONSTRAINT ak_guildxpconfig_guildid_ratetype UNIQUE (guildid, ratetype);
ALTER TABLE guildxpconfig ADD CONSTRAINT pk_guildxpconfig PRIMARY KEY (id);
ALTER TABLE channelxpconfig ADD CONSTRAINT ak_channelxpconfig_guildid_channelid_ratetype UNIQUE (guildid, channelid, ratetype);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250226215159_xp-rate-rework', '9.0.1');
COMMIT;

View file

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace EllieBot.Migrations.PostgreSql
{
[DbContext(typeof(PostgreSqlContext))]
[Migration("20250202124905_init")]
[Migration("20250226215222_init")]
partial class init
{
/// <inheritdoc />
@ -877,40 +877,6 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("discorduser", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<decimal>("ItemId")
.HasColumnType("numeric(20,0)")
.HasColumnName("itemid");
b.Property<int>("ItemType")
.HasColumnType("integer")
.HasColumnName("itemtype");
b.Property<int?>("XpSettingsId")
.HasColumnType("integer")
.HasColumnName("xpsettingsid");
b.HasKey("Id")
.HasName("pk_excludeditem");
b.HasIndex("XpSettingsId")
.HasDatabaseName("ix_excludeditem_xpsettingsid");
b.ToTable("excludeditem", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -3359,10 +3325,6 @@ namespace EllieBot.Migrations.PostgreSql
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<bool>("ServerExcluded")
.HasColumnType("boolean")
.HasColumnName("serverexcluded");
b.HasKey("Id")
.HasName("pk_xpsettings");
@ -3456,6 +3418,14 @@ namespace EllieBot.Migrations.PostgreSql
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("Bait")
.HasColumnType("integer")
.HasColumnName("bait");
b.Property<int?>("Pole")
.HasColumnType("integer")
.HasColumnName("pole");
b.Property<int>("Skill")
.HasColumnType("integer")
.HasColumnName("skill");
@ -3474,6 +3444,82 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("userfishstats", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("ChannelId")
.HasColumnType("numeric(20,0)")
.HasColumnName("channelid");
b.Property<float>("Cooldown")
.HasColumnType("real")
.HasColumnName("cooldown");
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<int>("RateType")
.HasColumnType("integer")
.HasColumnName("ratetype");
b.Property<long>("XpAmount")
.HasColumnType("bigint")
.HasColumnName("xpamount");
b.HasKey("Id")
.HasName("pk_channelxpconfig");
b.HasAlternateKey("GuildId", "ChannelId", "RateType")
.HasName("ak_channelxpconfig_guildid_channelid_ratetype");
b.ToTable("channelxpconfig", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Xp.GuildXpConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<float>("Cooldown")
.HasColumnType("real")
.HasColumnName("cooldown");
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<int>("RateType")
.HasColumnType("integer")
.HasColumnName("ratetype");
b.Property<long>("XpAmount")
.HasColumnType("bigint")
.HasColumnName("xpamount");
b.Property<string>("XpTemplateUrl")
.HasColumnType("text")
.HasColumnName("xptemplateurl");
b.HasKey("Id")
.HasName("pk_guildxpconfig");
b.HasAlternateKey("GuildId", "RateType")
.HasName("ak_guildxpconfig_guildid_ratetype");
b.ToTable("guildxpconfig", (string)null);
});
modelBuilder.Entity("EllieBot.Services.GreetSettings", b =>
{
b.Property<int>("Id")
@ -3677,14 +3723,6 @@ namespace EllieBot.Migrations.PostgreSql
b.Navigation("Club");
});
modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b =>
{
b.HasOne("EllieBot.Db.Models.XpSettings", null)
.WithMany("ExclusionList")
.HasForeignKey("XpSettingsId")
.HasConstraintName("fk_excludeditem_xpsettings_xpsettingsid");
});
modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b =>
{
b.HasOne("EllieBot.Db.Models.GuildFilterConfig", null)
@ -3971,11 +4009,9 @@ namespace EllieBot.Migrations.PostgreSql
{
b.Navigation("CurrencyRewards");
b.Navigation("ExclusionList");
b.Navigation("RoleRewards");
});
#pragma warning restore 612, 618
}
}
}
}

View file

@ -187,6 +187,24 @@ namespace EllieBot.Migrations.PostgreSql
table.UniqueConstraint("ak_buttonrole_roleid_messageid", x => new { x.roleid, x.messageid });
});
migrationBuilder.CreateTable(
name: "channelxpconfig",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ratetype = table.Column<int>(type: "integer", nullable: false),
guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
channelid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
xpamount = table.Column<long>(type: "bigint", nullable: false),
cooldown = table.Column<float>(type: "real", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_channelxpconfig", x => x.id);
table.UniqueConstraint("ak_channelxpconfig_guildid_channelid_ratetype", x => new { x.guildid, x.channelid, x.ratetype });
});
migrationBuilder.CreateTable(
name: "commandalias",
columns: table => new
@ -487,6 +505,24 @@ namespace EllieBot.Migrations.PostgreSql
table.PrimaryKey("pk_guildfilterconfig", x => x.id);
});
migrationBuilder.CreateTable(
name: "guildxpconfig",
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),
ratetype = table.Column<int>(type: "integer", nullable: false),
xpamount = table.Column<long>(type: "bigint", nullable: false),
cooldown = table.Column<float>(type: "real", nullable: false),
xptemplateurl = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_guildxpconfig", x => x.id);
table.UniqueConstraint("ak_guildxpconfig_guildid_ratetype", x => new { x.guildid, x.ratetype });
});
migrationBuilder.CreateTable(
name: "honeypotchannels",
columns: table => new
@ -1033,7 +1069,9 @@ namespace EllieBot.Migrations.PostgreSql
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
userid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
skill = table.Column<int>(type: "integer", nullable: false)
skill = table.Column<int>(type: "integer", nullable: false),
pole = table.Column<int>(type: "integer", nullable: true),
bait = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
@ -1118,7 +1156,6 @@ namespace EllieBot.Migrations.PostgreSql
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
serverexcluded = table.Column<bool>(type: "boolean", nullable: false),
dateadded = table.Column<DateTime>(type: "timestamp without time zone", nullable: true)
},
constraints: table =>
@ -1473,27 +1510,6 @@ namespace EllieBot.Migrations.PostgreSql
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "excludeditem",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
xpsettingsid = table.Column<int>(type: "integer", nullable: true),
itemid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
itemtype = table.Column<int>(type: "integer", nullable: false),
dateadded = table.Column<DateTime>(type: "timestamp without time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_excludeditem", x => x.id);
table.ForeignKey(
name: "fk_excludeditem_xpsettings_xpsettingsid",
column: x => x.xpsettingsid,
principalTable: "xpsettings",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "xpcurrencyreward",
columns: table => new
@ -1827,11 +1843,6 @@ namespace EllieBot.Migrations.PostgreSql
table: "discorduser",
column: "username");
migrationBuilder.CreateIndex(
name: "ix_excludeditem_xpsettingsid",
table: "excludeditem",
column: "xpsettingsid");
migrationBuilder.CreateIndex(
name: "ix_feedsub_guildid_url",
table: "feedsub",
@ -2310,6 +2321,9 @@ namespace EllieBot.Migrations.PostgreSql
migrationBuilder.DropTable(
name: "buttonrole");
migrationBuilder.DropTable(
name: "channelxpconfig");
migrationBuilder.DropTable(
name: "clubapplicants");
@ -2331,9 +2345,6 @@ namespace EllieBot.Migrations.PostgreSql
migrationBuilder.DropTable(
name: "discordpermoverrides");
migrationBuilder.DropTable(
name: "excludeditem");
migrationBuilder.DropTable(
name: "expressions");
@ -2376,6 +2387,9 @@ namespace EllieBot.Migrations.PostgreSql
migrationBuilder.DropTable(
name: "guildcolors");
migrationBuilder.DropTable(
name: "guildxpconfig");
migrationBuilder.DropTable(
name: "honeypotchannels");
@ -2551,4 +2565,4 @@ namespace EllieBot.Migrations.PostgreSql
name: "discorduser");
}
}
}
}

View file

@ -874,40 +874,6 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("discorduser", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<decimal>("ItemId")
.HasColumnType("numeric(20,0)")
.HasColumnName("itemid");
b.Property<int>("ItemType")
.HasColumnType("integer")
.HasColumnName("itemtype");
b.Property<int?>("XpSettingsId")
.HasColumnType("integer")
.HasColumnName("xpsettingsid");
b.HasKey("Id")
.HasName("pk_excludeditem");
b.HasIndex("XpSettingsId")
.HasDatabaseName("ix_excludeditem_xpsettingsid");
b.ToTable("excludeditem", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -3356,10 +3322,6 @@ namespace EllieBot.Migrations.PostgreSql
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<bool>("ServerExcluded")
.HasColumnType("boolean")
.HasColumnName("serverexcluded");
b.HasKey("Id")
.HasName("pk_xpsettings");
@ -3453,6 +3415,14 @@ namespace EllieBot.Migrations.PostgreSql
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("Bait")
.HasColumnType("integer")
.HasColumnName("bait");
b.Property<int?>("Pole")
.HasColumnType("integer")
.HasColumnName("pole");
b.Property<int>("Skill")
.HasColumnType("integer")
.HasColumnName("skill");
@ -3471,6 +3441,82 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("userfishstats", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("ChannelId")
.HasColumnType("numeric(20,0)")
.HasColumnName("channelid");
b.Property<float>("Cooldown")
.HasColumnType("real")
.HasColumnName("cooldown");
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<int>("RateType")
.HasColumnType("integer")
.HasColumnName("ratetype");
b.Property<long>("XpAmount")
.HasColumnType("bigint")
.HasColumnName("xpamount");
b.HasKey("Id")
.HasName("pk_channelxpconfig");
b.HasAlternateKey("GuildId", "ChannelId", "RateType")
.HasName("ak_channelxpconfig_guildid_channelid_ratetype");
b.ToTable("channelxpconfig", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Xp.GuildXpConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<float>("Cooldown")
.HasColumnType("real")
.HasColumnName("cooldown");
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<int>("RateType")
.HasColumnType("integer")
.HasColumnName("ratetype");
b.Property<long>("XpAmount")
.HasColumnType("bigint")
.HasColumnName("xpamount");
b.Property<string>("XpTemplateUrl")
.HasColumnType("text")
.HasColumnName("xptemplateurl");
b.HasKey("Id")
.HasName("pk_guildxpconfig");
b.HasAlternateKey("GuildId", "RateType")
.HasName("ak_guildxpconfig_guildid_ratetype");
b.ToTable("guildxpconfig", (string)null);
});
modelBuilder.Entity("EllieBot.Services.GreetSettings", b =>
{
b.Property<int>("Id")
@ -3674,14 +3720,6 @@ namespace EllieBot.Migrations.PostgreSql
b.Navigation("Club");
});
modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b =>
{
b.HasOne("EllieBot.Db.Models.XpSettings", null)
.WithMany("ExclusionList")
.HasForeignKey("XpSettingsId")
.HasConstraintName("fk_excludeditem_xpsettings_xpsettingsid");
});
modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b =>
{
b.HasOne("EllieBot.Db.Models.GuildFilterConfig", null)
@ -3968,11 +4006,9 @@ namespace EllieBot.Migrations.PostgreSql
{
b.Navigation("CurrencyRewards");
b.Navigation("ExclusionList");
b.Navigation("RoleRewards");
});
#pragma warning restore 612, 618
}
}
}
}

View file

@ -0,0 +1,25 @@
BEGIN TRANSACTION;
ALTER TABLE "UserFishStats" ADD "Bait" INTEGER NULL;
ALTER TABLE "UserFishStats" ADD "Pole" INTEGER NULL;
CREATE TABLE "ChannelXpConfig" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ChannelXpConfig" PRIMARY KEY AUTOINCREMENT,
"GuildId" INTEGER NOT NULL,
"ChannelId" INTEGER NOT NULL,
"XpAmount" INTEGER NOT NULL,
"Cooldown" REAL NOT NULL,
CONSTRAINT "AK_ChannelXpConfig_GuildId_ChannelId" UNIQUE ("GuildId", "ChannelId")
);
CREATE TABLE "GuildXpConfig" (
"GuildId" INTEGER NOT NULL CONSTRAINT "PK_GuildXpConfig" PRIMARY KEY AUTOINCREMENT,
"XpAmount" INTEGER NOT NULL,
"Cooldown" INTEGER NOT NULL,
"XpTemplateUrl" TEXT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250225212144_xp-excl-xp-rate', '9.0.1');
COMMIT;

View file

@ -0,0 +1,75 @@
BEGIN TRANSACTION;
DROP TABLE "ExcludedItem";
ALTER TABLE "GuildXpConfig" ADD "Id" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "GuildXpConfig" ADD "RateType" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "ChannelXpConfig" ADD "RateType" INTEGER NOT NULL DEFAULT 0;
CREATE TABLE "ef_temp_GuildXpConfig" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GuildXpConfig" PRIMARY KEY AUTOINCREMENT,
"Cooldown" REAL NOT NULL,
"GuildId" INTEGER NOT NULL,
"RateType" INTEGER NOT NULL,
"XpAmount" INTEGER NOT NULL,
"XpTemplateUrl" TEXT NULL,
CONSTRAINT "AK_GuildXpConfig_GuildId_RateType" UNIQUE ("GuildId", "RateType")
);
INSERT INTO "ef_temp_GuildXpConfig" ("Id", "Cooldown", "GuildId", "RateType", "XpAmount", "XpTemplateUrl")
SELECT "Id", "Cooldown", "GuildId", "RateType", "XpAmount", "XpTemplateUrl"
FROM "GuildXpConfig";
CREATE TABLE "ef_temp_ChannelXpConfig" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ChannelXpConfig" PRIMARY KEY AUTOINCREMENT,
"ChannelId" INTEGER NOT NULL,
"Cooldown" REAL NOT NULL,
"GuildId" INTEGER NOT NULL,
"RateType" INTEGER NOT NULL,
"XpAmount" INTEGER NOT NULL,
CONSTRAINT "AK_ChannelXpConfig_GuildId_ChannelId_RateType" UNIQUE ("GuildId", "ChannelId", "RateType")
);
INSERT INTO "ef_temp_ChannelXpConfig" ("Id", "ChannelId", "Cooldown", "GuildId", "RateType", "XpAmount")
SELECT "Id", "ChannelId", "Cooldown", "GuildId", "RateType", "XpAmount"
FROM "ChannelXpConfig";
CREATE TABLE "ef_temp_XpSettings" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_XpSettings" PRIMARY KEY AUTOINCREMENT,
"DateAdded" TEXT NULL,
"GuildId" INTEGER NOT NULL
);
INSERT INTO "ef_temp_XpSettings" ("Id", "DateAdded", "GuildId")
SELECT "Id", "DateAdded", "GuildId"
FROM "XpSettings";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "GuildXpConfig";
ALTER TABLE "ef_temp_GuildXpConfig" RENAME TO "GuildXpConfig";
DROP TABLE "ChannelXpConfig";
ALTER TABLE "ef_temp_ChannelXpConfig" RENAME TO "ChannelXpConfig";
DROP TABLE "XpSettings";
ALTER TABLE "ef_temp_XpSettings" RENAME TO "XpSettings";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE UNIQUE INDEX "IX_XpSettings_GuildId" ON "XpSettings" ("GuildId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250226215156_xp-rate-rework', '9.0.1');

View file

@ -11,7 +11,7 @@ using EllieBot.Db;
namespace EllieBot.Migrations.Sqlite
{
[DbContext(typeof(SqliteContext))]
[Migration("20250202124903_init")]
[Migration("20250226215220_init")]
partial class init
{
/// <inheritdoc />
@ -657,31 +657,6 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("DiscordUser");
});
modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<ulong>("ItemId")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<int?>("XpSettingsId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("XpSettingsId");
b.ToTable("ExcludedItem");
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -2499,9 +2474,6 @@ namespace EllieBot.Migrations.Sqlite
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<bool>("ServerExcluded")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GuildId")
@ -2571,6 +2543,12 @@ namespace EllieBot.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("Bait")
.HasColumnType("INTEGER");
b.Property<int?>("Pole")
.HasColumnType("INTEGER");
b.Property<int>("Skill")
.HasColumnType("INTEGER");
@ -2585,6 +2563,62 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("UserFishStats");
});
modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("ChannelId")
.HasColumnType("INTEGER");
b.Property<float>("Cooldown")
.HasColumnType("REAL");
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<int>("RateType")
.HasColumnType("INTEGER");
b.Property<long>("XpAmount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasAlternateKey("GuildId", "ChannelId", "RateType");
b.ToTable("ChannelXpConfig");
});
modelBuilder.Entity("EllieBot.Modules.Xp.GuildXpConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<float>("Cooldown")
.HasColumnType("REAL");
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<int>("RateType")
.HasColumnType("INTEGER");
b.Property<long>("XpAmount")
.HasColumnType("INTEGER");
b.Property<string>("XpTemplateUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasAlternateKey("GuildId", "RateType");
b.ToTable("GuildXpConfig");
});
modelBuilder.Entity("EllieBot.Services.GreetSettings", b =>
{
b.Property<int>("Id")
@ -2752,13 +2786,6 @@ namespace EllieBot.Migrations.Sqlite
b.Navigation("Club");
});
modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b =>
{
b.HasOne("EllieBot.Db.Models.XpSettings", null)
.WithMany("ExclusionList")
.HasForeignKey("XpSettingsId");
});
modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b =>
{
b.HasOne("EllieBot.Db.Models.GuildFilterConfig", null)
@ -3023,11 +3050,9 @@ namespace EllieBot.Migrations.Sqlite
{
b.Navigation("CurrencyRewards");
b.Navigation("ExclusionList");
b.Navigation("RoleRewards");
});
#pragma warning restore 612, 618
}
}
}
}

View file

@ -186,6 +186,24 @@ namespace EllieBot.Migrations.Sqlite
table.UniqueConstraint("AK_ButtonRole_RoleId_MessageId", x => new { x.RoleId, x.MessageId });
});
migrationBuilder.CreateTable(
name: "ChannelXpConfig",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RateType = table.Column<int>(type: "INTEGER", nullable: false),
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
ChannelId = table.Column<ulong>(type: "INTEGER", nullable: false),
XpAmount = table.Column<long>(type: "INTEGER", nullable: false),
Cooldown = table.Column<float>(type: "REAL", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelXpConfig", x => x.Id);
table.UniqueConstraint("AK_ChannelXpConfig_GuildId_ChannelId_RateType", x => new { x.GuildId, x.ChannelId, x.RateType });
});
migrationBuilder.CreateTable(
name: "CommandAlias",
columns: table => new
@ -486,6 +504,24 @@ namespace EllieBot.Migrations.Sqlite
table.PrimaryKey("PK_GuildFilterConfig", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GuildXpConfig",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
RateType = table.Column<int>(type: "INTEGER", nullable: false),
XpAmount = table.Column<long>(type: "INTEGER", nullable: false),
Cooldown = table.Column<float>(type: "REAL", nullable: false),
XpTemplateUrl = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GuildXpConfig", x => x.Id);
table.UniqueConstraint("AK_GuildXpConfig_GuildId_RateType", x => new { x.GuildId, x.RateType });
});
migrationBuilder.CreateTable(
name: "HoneyPotChannels",
columns: table => new
@ -1035,7 +1071,9 @@ namespace EllieBot.Migrations.Sqlite
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
Skill = table.Column<int>(type: "INTEGER", nullable: false)
Skill = table.Column<int>(type: "INTEGER", nullable: false),
Pole = table.Column<int>(type: "INTEGER", nullable: true),
Bait = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
@ -1120,7 +1158,6 @@ namespace EllieBot.Migrations.Sqlite
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
ServerExcluded = table.Column<bool>(type: "INTEGER", nullable: false),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
@ -1475,27 +1512,6 @@ namespace EllieBot.Migrations.Sqlite
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExcludedItem",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
XpSettingsId = table.Column<int>(type: "INTEGER", nullable: true),
ItemId = table.Column<ulong>(type: "INTEGER", nullable: false),
ItemType = table.Column<int>(type: "INTEGER", nullable: false),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExcludedItem", x => x.Id);
table.ForeignKey(
name: "FK_ExcludedItem_XpSettings_XpSettingsId",
column: x => x.XpSettingsId,
principalTable: "XpSettings",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "XpCurrencyReward",
columns: table => new
@ -1829,11 +1845,6 @@ namespace EllieBot.Migrations.Sqlite
table: "DiscordUser",
column: "Username");
migrationBuilder.CreateIndex(
name: "IX_ExcludedItem_XpSettingsId",
table: "ExcludedItem",
column: "XpSettingsId");
migrationBuilder.CreateIndex(
name: "IX_FeedSub_GuildId_Url",
table: "FeedSub",
@ -2312,6 +2323,9 @@ namespace EllieBot.Migrations.Sqlite
migrationBuilder.DropTable(
name: "ButtonRole");
migrationBuilder.DropTable(
name: "ChannelXpConfig");
migrationBuilder.DropTable(
name: "ClubApplicants");
@ -2333,9 +2347,6 @@ namespace EllieBot.Migrations.Sqlite
migrationBuilder.DropTable(
name: "DiscordPermOverrides");
migrationBuilder.DropTable(
name: "ExcludedItem");
migrationBuilder.DropTable(
name: "Expressions");
@ -2378,6 +2389,9 @@ namespace EllieBot.Migrations.Sqlite
migrationBuilder.DropTable(
name: "GuildColors");
migrationBuilder.DropTable(
name: "GuildXpConfig");
migrationBuilder.DropTable(
name: "HoneyPotChannels");
@ -2553,4 +2567,4 @@ namespace EllieBot.Migrations.Sqlite
name: "DiscordUser");
}
}
}
}

View file

@ -654,31 +654,6 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("DiscordUser");
});
modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<ulong>("ItemId")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<int?>("XpSettingsId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("XpSettingsId");
b.ToTable("ExcludedItem");
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -2496,9 +2471,6 @@ namespace EllieBot.Migrations.Sqlite
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<bool>("ServerExcluded")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GuildId")
@ -2568,6 +2540,12 @@ namespace EllieBot.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("Bait")
.HasColumnType("INTEGER");
b.Property<int?>("Pole")
.HasColumnType("INTEGER");
b.Property<int>("Skill")
.HasColumnType("INTEGER");
@ -2582,6 +2560,62 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("UserFishStats");
});
modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("ChannelId")
.HasColumnType("INTEGER");
b.Property<float>("Cooldown")
.HasColumnType("REAL");
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<int>("RateType")
.HasColumnType("INTEGER");
b.Property<long>("XpAmount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasAlternateKey("GuildId", "ChannelId", "RateType");
b.ToTable("ChannelXpConfig");
});
modelBuilder.Entity("EllieBot.Modules.Xp.GuildXpConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<float>("Cooldown")
.HasColumnType("REAL");
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<int>("RateType")
.HasColumnType("INTEGER");
b.Property<long>("XpAmount")
.HasColumnType("INTEGER");
b.Property<string>("XpTemplateUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasAlternateKey("GuildId", "RateType");
b.ToTable("GuildXpConfig");
});
modelBuilder.Entity("EllieBot.Services.GreetSettings", b =>
{
b.Property<int>("Id")
@ -2749,13 +2783,6 @@ namespace EllieBot.Migrations.Sqlite
b.Navigation("Club");
});
modelBuilder.Entity("EllieBot.Db.Models.ExcludedItem", b =>
{
b.HasOne("EllieBot.Db.Models.XpSettings", null)
.WithMany("ExclusionList")
.HasForeignKey("XpSettingsId");
});
modelBuilder.Entity("EllieBot.Db.Models.FilterChannelId", b =>
{
b.HasOne("EllieBot.Db.Models.GuildFilterConfig", null)
@ -3020,11 +3047,9 @@ namespace EllieBot.Migrations.Sqlite
{
b.Navigation("CurrencyRewards");
b.Navigation("ExclusionList");
b.Navigation("RoleRewards");
});
#pragma warning restore 612, 618
}
}
}
}

View file

@ -11,4 +11,5 @@ public sealed class UserXpBatch
public ulong GuildId { get; set; }
public string Username { get; set; } = string.Empty;
public string AvatarId { get; set; } = string.Empty;
public long XpToGain { get; set; } = 0;
}

View file

@ -53,91 +53,6 @@ public partial class Xp : EllieModule<XpService>
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task XpExclude(Server _)
{
var ex = await _service.ToggleExcludeServerAsync(ctx.Guild.Id);
if (ex)
await Response().Confirm(strs.excluded(Format.Bold(ctx.Guild.ToString()))).SendAsync();
else
await Response().Confirm(strs.not_excluded(Format.Bold(ctx.Guild.ToString()))).SendAsync();
}
[Cmd]
[UserPerm(GuildPerm.ManageRoles)]
[RequireContext(ContextType.Guild)]
public async Task XpExclude(Role _, [Leftover] IRole role)
{
var ex = await _service.ToggleExcludeRoleAsync(ctx.Guild.Id, role.Id);
if (ex)
await Response().Confirm(strs.excluded(Format.Bold(role.ToString()))).SendAsync();
else
await Response().Confirm(strs.not_excluded(Format.Bold(role.ToString()))).SendAsync();
}
[Cmd]
[UserPerm(GuildPerm.ManageChannels)]
[RequireContext(ContextType.Guild)]
public async Task XpExclude(Channel _, [Leftover] IChannel? channel = null)
{
if (channel is null)
channel = ctx.Channel;
var ex = await _service.ToggleExcludeChannelAsync(ctx.Guild.Id, channel.Id);
if (ex)
await Response().Confirm(strs.excluded(Format.Bold(channel.ToString()))).SendAsync();
else
await Response().Confirm(strs.not_excluded(Format.Bold(channel.ToString()))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpExclusionList()
{
var serverExcluded = _service.IsServerExcluded(ctx.Guild.Id);
var roles = _service.GetExcludedRoles(ctx.Guild.Id)
.Select(x => ctx.Guild.GetRole(x))
.Where(x => x is not null)
.Select(x => $"`role` {x.Mention}")
.ToList();
var chans = (await _service.GetExcludedChannels(ctx.Guild.Id)
.Select(x => ctx.Guild.GetChannelAsync(x))
.WhenAll()).Where(x => x is not null)
.Select(x => $"`channel` <#{x.Id}>")
.ToList();
var rolesStr = roles.Any() ? string.Join("\n", roles) + "\n" : string.Empty;
var chansStr = chans.Count > 0 ? string.Join("\n", chans) + "\n" : string.Empty;
var desc = Format.Code(serverExcluded
? GetText(strs.server_is_excluded)
: GetText(strs.server_is_not_excluded));
desc += "\n\n" + rolesStr + chansStr;
var lines = desc.Split('\n');
await Response()
.Paginated()
.Items(lines)
.PageSize(15)
.CurrentPage(0)
.Page((items, _) =>
{
var embed = CreateEmbed()
.WithTitle(GetText(strs.exclusion_list))
.WithDescription(string.Join('\n', items))
.WithOkColor();
return embed;
})
.SendAsync();
}
[Cmd]
[EllieOptions<LbOpts>]
[Priority(0)]

View file

@ -0,0 +1,172 @@
using System.Runtime.CompilerServices;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Modules.Xp.Services;
namespace EllieBot.Modules.Xp;
using GuildXpRates = (IReadOnlyList<GuildXpConfig> GuildRates, IReadOnlyList<ChannelXpConfig> ChannelRates);
public class GuildConfigXpService(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();
public async Task OnReadyAsync()
{
await using var uow = db.GetDbContext();
_guildRates = await uow.GetTable<GuildXpConfig>()
.AsNoTracking()
.Where(x => Queries.GuildOnShard(x.GuildId, shardData.TotalShards, shardData.ShardId))
.ToListAsyncLinqToDB()
.Fmap(list =>
list
.ToDictionary(
x => (x.RateType, x.GuildId),
x => new XpRate(x.RateType, x.XpAmount, x.Cooldown))
.ToConcurrent());
_channelRates = await uow.GetTable<ChannelXpConfig>()
.AsNoTracking()
.Where(x => Queries.GuildOnShard(x.GuildId, shardData.TotalShards, shardData.ShardId))
.ToListAsyncLinqToDB()
.Fmap(x =>
x.GroupBy(x => x.GuildId)
.ToDictionary(
x => x.Key,
x => x.ToDictionary(
y => (y.RateType, y.ChannelId),
y => new XpRate(y.RateType, y.XpAmount, y.Cooldown))
.ToConcurrent())
.ToConcurrent());
}
public async Task<GuildXpRates> GetGuildXpRatesAsync(ulong guildId)
{
await using var uow = db.GetDbContext();
var guildConfig = await uow.GetTable<GuildXpConfig>()
.AsNoTracking()
.Where(x => x.GuildId == guildId)
.ToListAsyncLinqToDB();
var channelRates = await uow.GetTable<ChannelXpConfig>()
.AsNoTracking()
.Where(x => x.GuildId == guildId)
.ToListAsyncLinqToDB();
return (guildConfig, channelRates);
}
public async Task SetGuildXpRateAsync(ulong guildId, XpRateType type, long amount, float cooldown)
{
AmountAndCooldownChecks(amount, cooldown);
if (type == XpRateType.Voice)
cooldown = 1.0f;
await using var uow = db.GetDbContext();
await uow.GetTable<GuildXpConfig>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
RateType = type,
XpAmount = amount,
Cooldown = cooldown,
},
(_) => new()
{
Cooldown = cooldown,
XpAmount = amount,
},
() => new()
{
GuildId = guildId,
RateType = type,
});
}
public async Task SetChannelXpRateAsync(ulong guildId,
XpRateType type,
ulong channelId,
long amount,
float cooldown)
{
AmountAndCooldownChecks(amount, cooldown);
if (type == XpRateType.Voice)
cooldown = 1.0f;
await using var uow = db.GetDbContext();
await uow.GetTable<ChannelXpConfig>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
ChannelId = channelId,
XpAmount = amount,
Cooldown = cooldown,
RateType = type
},
(_) => new()
{
Cooldown = cooldown,
XpAmount = amount,
},
() => new()
{
GuildId = guildId,
ChannelId = channelId,
RateType = type,
});
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void AmountAndCooldownChecks(long amount, float cooldown)
{
ArgumentOutOfRangeException.ThrowIfNegative(amount, nameof(amount));
ArgumentOutOfRangeException.ThrowIfGreaterThan(amount, 1000, nameof(amount));
ArgumentOutOfRangeException.ThrowIfNegative(cooldown, nameof(cooldown));
ArgumentOutOfRangeException.ThrowIfGreaterThan(cooldown, 1440, nameof(cooldown));
}
public async Task<bool> ResetGuildXpRateAsync(ulong guildId)
{
await using var uow = db.GetDbContext();
var deleted = await uow.GetTable<GuildXpConfig>()
.Where(x => x.GuildId == guildId)
.DeleteAsync();
return deleted > 0;
}
public async Task<bool> ResetChannelXpRateAsync(ulong guildId, ulong channelId)
{
await using var uow = db.GetDbContext();
var deleted = await uow.GetTable<ChannelXpConfig>()
.Where(x => x.GuildId == guildId && x.ChannelId == channelId)
.DeleteAsync();
return deleted > 0;
}
public XpRate GetXpRate(XpRateType type, ulong guildId, ulong channelId)
{
if (_channelRates.TryGetValue(guildId, out var guildChannelRates))
{
if (guildChannelRates.TryGetValue((type, channelId), out var rate))
return rate;
}
if (_guildRates.TryGetValue((type, guildId), out var guildRate))
return guildRate;
var conf = xcs.Data;
return type switch
{
XpRateType.Image => new XpRate(XpRateType.Image, conf.TextXpFromImage, conf.TextXpCooldown / 60.0f),
XpRateType.Voice => new XpRate(XpRateType.Voice, conf.VoiceXpPerMinute, 1.0f),
_ => new XpRate(XpRateType.Text, conf.TextXpPerMessage, conf.TextXpCooldown / 60.0f),
};
}
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Modules.Xp;
public readonly record struct XpRate(XpRateType Type, long Amount, float Cooldown)
{
public bool IsExcluded()
=> Amount == 0 || Cooldown == 0;
}

View file

@ -0,0 +1,116 @@
namespace EllieBot.Modules.Xp;
public partial class Xp
{
[RequireUserPermission(GuildPermission.ManageGuild)]
public class XpRateCommands : EllieModule<GuildConfigXpService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpRate()
{
var rates = await _service.GetGuildXpRatesAsync(ctx.Guild.Id);
if (!rates.GuildRates.Any() && !rates.ChannelRates.Any())
{
await Response().Pending(strs.xp_rate_none).SendAsync();
return;
}
await Response()
.Paginated()
.Items(rates.ChannelRates.GroupBy(x => x.ChannelId).ToList())
.PageSize(5)
.Page((items, _) =>
{
var eb = CreateEmbed()
.WithOkColor();
if (rates.GuildRates is not { Count: <= 0 })
{
eb.AddField(GetText(strs.xp_rate_server),
rates.GuildRates
.Select(x => GetText(strs.xp_rate_str(x.RateType, x.XpAmount, x.Cooldown)))
.Join('\n'));
}
if (items.Any())
{
var channelRates = items
.Select(x => $"""
<#{x.Key}>
{x.Select(c => $"- {GetText(strs.xp_rate_str(c.RateType, c.XpAmount, c.Cooldown))}").Join('\n')}
""")
.Join('\n');
eb.AddField(GetText(strs.xp_rate_channels), channelRates);
}
return eb;
})
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpRate(XpRateType type, int amount, float minutes)
{
if (amount is < 0 or > 1000)
{
await Response().Error(strs.xp_rate_amount_invalid).SendAsync();
return;
}
if (minutes is < 0 or > 1440)
{
await Response().Error(strs.xp_rate_cooldown_invalid).SendAsync();
return;
}
await _service.SetGuildXpRateAsync(ctx.Guild.Id, type, amount, (int)Math.Ceiling(minutes));
await Response().Confirm(strs.xp_rate_server_set(amount, minutes)).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpRate(IMessageChannel channel, XpRateType type, int amount, float minutes)
{
if (amount is < 0 or > 1000)
{
await Response().Error(strs.xp_rate_amount_invalid).SendAsync();
return;
}
if (minutes is < 0 or > 1440)
{
await Response().Error(strs.xp_rate_cooldown_invalid).SendAsync();
return;
}
await _service.SetChannelXpRateAsync(ctx.Guild.Id, type, channel.Id, amount, (int)Math.Ceiling(minutes));
await Response()
.Confirm(strs.xp_rate_channel_set(Format.Bold(channel.ToString()), amount, minutes))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpRateReset()
{
await _service.ResetGuildXpRateAsync(ctx.Guild.Id);
await Response().Confirm(strs.xp_rate_server_reset).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpRateReset(IMessageChannel channel)
=> await XpRateReset(channel.Id);
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpRateReset(ulong channelId)
{
await _service.ResetChannelXpRateAsync(ctx.Guild.Id, channelId);
await Response().Confirm(strs.xp_rate_channel_reset($"<#{channelId}>")).SendAsync();
}
}
}

View file

@ -0,0 +1,8 @@
namespace EllieBot.Modules.Xp;
public enum XpRateType
{
Text,
Voice,
Image,
}

View file

@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace EllieBot.Modules.Xp;
public class ChannelXpConfig
{
[Key]
public int Id { get; set; }
public XpRateType RateType { get; set; }
public ulong GuildId { get; set; }
public ulong ChannelId { get; set; }
public long XpAmount { get; set; }
public float Cooldown { get; set; }
}
public sealed class ChannelXpConfigEntity : IEntityTypeConfiguration<ChannelXpConfig>
{
public void Configure(EntityTypeBuilder<ChannelXpConfig> builder)
{
builder.HasAlternateKey(x => new
{
x.GuildId,
x.ChannelId,
x.RateType,
});
}
}

View file

@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace EllieBot.Modules.Xp;
public class GuildXpConfig
{
[Key]
public int Id { get; set; }
public ulong GuildId { get; set; }
public XpRateType RateType { get; set; }
public long XpAmount { get; set; }
public float Cooldown { get; set; }
public string? XpTemplateUrl { get; set; }
}
public sealed class GuildXpConfigEntity : IEntityTypeConfiguration<GuildXpConfig>
{
public void Configure(EntityTypeBuilder<GuildXpConfig> builder)
{
builder.HasAlternateKey(x => new
{
x.GuildId,
x.RateType,
});
}
}

View file

@ -32,11 +32,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
private readonly ICurrencyService _cs;
private readonly IHttpClientFactory _httpFactory;
private readonly XpConfigService _xpConfig;
private readonly IPubSub _pubSub;
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedRoles = new();
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedChannels = new();
private readonly ConcurrentHashSet<ulong> _excludedServers;
private readonly DiscordSocketClient _client;
@ -45,8 +40,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
private readonly INotifySubscriber _notifySub;
private readonly IMemoryCache _memCache;
private readonly ShardData _shardData;
private readonly XpTemplateService _templateService;
private readonly GuildConfigXpService _xpRateService;
private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 100);
@ -65,7 +60,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
INotifySubscriber notifySub,
IMemoryCache memCache,
ShardData shardData,
XpTemplateService templateService)
XpTemplateService templateService,
GuildConfigXpService xpRateService)
{
_db = db;
_images = images;
@ -74,47 +70,17 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
_cs = cs;
_httpFactory = http;
_xpConfig = xpConfig;
_pubSub = pubSub;
_notifySub = notifySub;
_memCache = memCache;
_shardData = shardData;
_templateService = templateService;
_excludedServers = [];
_excludedChannels = new();
_xpRateService = xpRateService;
_client = client;
_ps = ps;
_c = c;
if (client.ShardId == 0)
{
}
}
public async Task OnReadyAsync()
{
// initialize ignored
await using (var ctx = _db.GetDbContext())
{
var xps = await ctx.GetTable<XpSettings>()
.Where(x => Queries.GuildOnShard(x.GuildId, _shardData.TotalShards, _shardData.ShardId))
.LoadWith(x => x.ExclusionList)
.ToListAsyncLinqToDB();
foreach (var xp in xps)
{
if (xp.ServerExcluded)
_excludedServers.Add(xp.GuildId);
foreach (var item in xp.ExclusionList)
{
if (item.ItemType == ExcludedItemType.Channel)
_excludedChannels.GetOrAdd(xp.GuildId, static _ => []).Add(item.ItemId);
else if (item.ItemType == ExcludedItemType.Role)
_excludedRoles.GetOrAdd(xp.GuildId, static _ => []).Add(item.ItemId);
}
}
}
await Task.WhenAll(UpdateTimer(), VoiceUpdateTimer(), _levelUpQueue.RunAsync());
return;
@ -156,65 +122,66 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
/// <summary>
/// The current batch of users that will gain xp
/// </summary>
private readonly ConcurrentHashSet<IGuildUser> _usersBatch = [];
private readonly ConcurrentHashSet<XpQueueEntry> _usersBatch = [];
/// <summary>
/// The current batch of users that will gain voice xp
/// </summary>
private readonly ConcurrentHashSet<IGuildUser> _voiceXpBatch = [];
private readonly HashSet<IGuildUser> _voiceXpBatch = [];
private async Task UpdateVoiceXp()
{
var xpAmount = _xpConfig.Data.VoiceXpPerMinute;
if (xpAmount <= 0)
return;
var oldBatch = _voiceXpBatch.ToArray();
var oldBatch = _voiceXpBatch.ToHashSet();
_voiceXpBatch.Clear();
var validUsers = new HashSet<IGuildUser>();
var validUsers = new List<XpQueueEntry>(oldBatch.Count);
var guilds = _client.Guilds;
foreach (var g in guilds)
{
if (IsServerExcluded(g.Id))
continue;
foreach (var vc in g.VoiceChannels)
{
if (!IsVoiceChannelActive(vc))
continue;
if (IsChannelExcluded(vc))
var rate = _xpRateService.GetXpRate(XpRateType.Voice, g.Id, vc.Id);
if (rate.IsExcluded())
continue;
foreach (var u in vc.ConnectedUsers)
{
if (IsServerOrRoleExcluded(u) || !UserParticipatingInVoiceChannel(u))
if (!UserParticipatingInVoiceChannel(u))
continue;
if (oldBatch.Contains(u))
validUsers.Add(u);
{
validUsers.Add(new(u, rate.Amount));
}
_voiceXpBatch.Add(u);
}
}
}
await UpdateXpInternalAsync(validUsers.DistinctBy(x => x.Id).ToArray(), xpAmount);
await UpdateXpInternalAsync(validUsers.ToArray());
}
private async Task UpdateXp()
{
var xpAmount = _xpConfig.Data.TextXpPerMessage;
// might want to lock this, but it's not a big deal
// or do something like this
// foreach (var item in currentBatch)
// _usersBatch.TryRemove(item);
var currentBatch = _usersBatch.ToArray();
_usersBatch.Clear();
await UpdateXpInternalAsync(currentBatch, xpAmount);
await UpdateXpInternalAsync(currentBatch);
}
private async Task UpdateXpInternalAsync(IGuildUser[] currentBatch, int xpAmount)
private async Task UpdateXpInternalAsync(XpQueueEntry[] currentBatch)
{
if (currentBatch.Length == 0)
return;
@ -227,16 +194,17 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
await batchTable.BulkCopyAsync(currentBatch.Select(x => new UserXpBatch()
{
GuildId = x.GuildId,
UserId = x.Id,
Username = x.Username,
AvatarId = x.DisplayAvatarId
GuildId = x.User.GuildId,
UserId = x.User.Id,
Username = x.User.Username,
AvatarId = x.User.DisplayAvatarId,
XpToGain = x.Xp
}));
await lctx.ExecuteAsync(
$"""
INSERT INTO UserXpStats (GuildId, UserId, Xp)
SELECT "{tempTableName}"."GuildId", "{tempTableName}"."UserId", {xpAmount}
SELECT "{tempTableName}"."GuildId", "{tempTableName}"."UserId", "XpToGain"
FROM {tempTableName}
WHERE TRUE
ON CONFLICT (GuildId, UserId) DO UPDATE
@ -251,9 +219,13 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
(batch, stats) => stats)
.ToListAsyncLinqToDB();
var userToXp = currentBatch.ToDictionary(x => x.User.Id, x => x.Xp);
foreach (var u in updated)
{
var oldStats = new LevelStats(u.Xp - xpAmount);
if (!userToXp.TryGetValue(u.UserId, out var xpGained))
continue;
var oldStats = new LevelStats(u.Xp - xpGained);
var newStats = new LevelStats(u.Xp);
Log.Information("User {User} xp updated from {OldLevel} to {NewLevel}",
@ -531,8 +503,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
{
if (UserParticipatingInVoiceChannel(user))
{
count++;
if (count >= 2)
if (++count >= 2)
return true;
}
}
@ -543,27 +514,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
private static bool UserParticipatingInVoiceChannel(SocketGuildUser user)
=> !user.IsDeafened && !user.IsMuted && !user.IsSelfDeafened && !user.IsSelfMuted;
private bool IsServerOrRoleExcluded(SocketGuildUser user)
{
if (_excludedServers.Contains(user.Guild.Id))
return true;
if (_excludedRoles.TryGetValue(user.Guild.Id, out var roles) && user.Roles.Any(x => roles.Contains(x.Id)))
return true;
return false;
}
private bool IsChannelExcluded(IGuildChannel channel)
{
if (_excludedChannels.TryGetValue(channel.Guild.Id, out var chans)
&& (chans.Contains(channel.Id)
|| (channel is SocketThreadChannel tc && chans.Contains(tc.ParentChannel.Id))))
return true;
return false;
}
public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage arg)
{
if (arg.Author is not SocketGuildUser user || user.IsBot)
@ -574,27 +524,36 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
_ = Task.Run(async () =>
{
if (IsChannelExcluded(gc))
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);
XpRate rate;
if (isImage)
{
var imageRate = _xpRateService.GetXpRate(XpRateType.Image, guild.Id, gc.Id);
if (imageRate.IsExcluded())
return;
rate = imageRate;
}
else if (isText)
{
if (textRate.IsExcluded())
return;
rate = textRate;
}
else
{
return;
}
if (!await TryAddUserGainedXpAsync(user.Id, rate.Cooldown))
return;
if (IsServerOrRoleExcluded(user))
return;
var xpConf = _xpConfig.Data;
var xp = 0;
if (arg.Attachments.Any(a => a.Height >= 128 && a.Width >= 128))
xp = xpConf.TextXpFromImage;
if (arg.Content.Contains(' ') || arg.Content.Length >= 5)
xp = Math.Max(xp, xpConf.TextXpPerMessage);
if (xp <= 0)
return;
if (!await TryAddUserGainedXpAsync(user.Id, xpConf.TextXpCooldown))
return;
_usersBatch.Add(user);
_usersBatch.Add(new(user, rate.Amount));
});
return Task.CompletedTask;
@ -621,28 +580,9 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
await uow.SaveChangesAsync();
}
public bool IsServerExcluded(ulong id)
=> _excludedServers.Contains(id);
public IEnumerable<ulong> GetExcludedRoles(ulong id)
private Task<bool> TryAddUserGainedXpAsync(ulong userId, float cdInMinutes)
{
if (_excludedRoles.TryGetValue(id, out var val))
return val.ToArray();
return [];
}
public IEnumerable<ulong> GetExcludedChannels(ulong id)
{
if (_excludedChannels.TryGetValue(id, out var val))
return val.ToArray();
return [];
}
private Task<bool> TryAddUserGainedXpAsync(ulong userId, int cdInSeconds)
{
if (cdInSeconds <= 0)
if (cdInMinutes <= float.Epsilon)
return Task.FromResult(true);
if (_memCache.TryGetValue(userId, out _))
@ -651,7 +591,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
using var entry = _memCache.CreateEntry(userId);
entry.Value = true;
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(cdInSeconds);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cdInMinutes);
return Task.FromResult(true);
}
@ -674,81 +614,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
guildRank);
}
public async Task<bool> ToggleExcludeServerAsync(ulong id)
{
await using var uow = _db.GetDbContext();
var xpSetting = await uow.XpSettingsFor(id);
if (_excludedServers.Add(id))
{
xpSetting.ServerExcluded = true;
await uow.SaveChangesAsync();
return true;
}
_excludedServers.TryRemove(id);
xpSetting.ServerExcluded = false;
await uow.SaveChangesAsync();
return false;
}
public async Task<bool> ToggleExcludeRoleAsync(ulong guildId, ulong rId)
{
var roles = _excludedRoles.GetOrAdd(guildId, _ => []);
await using var uow = _db.GetDbContext();
var xpSetting = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.ExclusionList));
var excludeObj = new ExcludedItem
{
ItemId = rId,
ItemType = ExcludedItemType.Role
};
if (roles.Add(rId))
{
if (xpSetting.ExclusionList.Add(excludeObj))
await uow.SaveChangesAsync();
return true;
}
roles.TryRemove(rId);
var toDelete = xpSetting.ExclusionList.FirstOrDefault(x => x.Equals(excludeObj));
if (toDelete is not null)
{
uow.Remove(toDelete);
await uow.SaveChangesAsync();
}
return false;
}
public async Task<bool> ToggleExcludeChannelAsync(ulong guildId, ulong chId)
{
var channels = _excludedChannels.GetOrAdd(guildId, _ => []);
await using var uow = _db.GetDbContext();
var xpSetting = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.ExclusionList));
var excludeObj = new ExcludedItem
{
ItemId = chId,
ItemType = ExcludedItemType.Channel
};
if (channels.Add(chId))
{
if (xpSetting.ExclusionList.Add(excludeObj))
await uow.SaveChangesAsync();
return true;
}
channels.TryRemove(chId);
if (xpSetting.ExclusionList.Remove(excludeObj))
await uow.SaveChangesAsync();
return false;
}
public async Task<(Stream Image, IImageFormat Format)> GenerateXpImageAsync(IGuildUser user)
{
var stats = await GetUserStatsAsync(user);
@ -1224,8 +1089,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
if (item is null || item.Price < 0)
return BuyResult.UnknownItem;
if (item.Price > 0 &&
!await _cs.RemoveAsync(userId, item.Price, new("xpshop", "buy", $"Background {key}")))
if (item.Price > 0 && !await _cs.RemoveAsync(userId, item.Price, new("xpshop", "buy", $"Background {key}")))
return BuyResult.InsufficientFunds;
@ -1438,4 +1302,13 @@ public sealed class XpTemplateService : IEService, IReadyExecutor
public XpTemplate GetTemplate()
=> _template;
}
public readonly record struct XpQueueEntry(IGuildUser User, long Xp)
{
public bool Equals(XpQueueEntry? other)
=> other?.User == User;
public override int GetHashCode()
=> User.GetHashCode();
}

View file

@ -35,9 +35,9 @@ public class XpSvc : GrpcXp.GrpcXpBase, IGrpcSvc, IEService
if (guild is null)
throw new RpcException(new Status(StatusCode.NotFound, "Guild not found"));
var excludedChannels = _xp.GetExcludedChannels(request.GuildId);
var excludedRoles = _xp.GetExcludedRoles(request.GuildId);
var isServerExcluded = _xp.IsServerExcluded(request.GuildId);
var excludedChannels = new List<ulong>();
var excludedRoles = new List<ulong>();
var isServerExcluded = false;
var reply = new GetXpSettingsReply();
@ -82,59 +82,6 @@ public class XpSvc : GrpcXp.GrpcXpBase, IGrpcSvc, IEService
return reply;
}
public override async Task<AddExclusionReply> AddExclusion(AddExclusionRequest request, ServerCallContext context)
{
await Task.Yield();
var success = false;
var guild = _client.GetGuild(request.GuildId);
if (guild is null)
throw new RpcException(new Status(StatusCode.NotFound, "Guild not found"));
if (request.Type == "Role")
{
if (guild.GetRole(request.Id) is null)
return new()
{
Success = false
};
success = await _xp.ToggleExcludeRoleAsync(request.GuildId, request.Id);
}
else if (request.Type == "Channel")
{
if (guild.GetTextChannel(request.Id) is null)
return new()
{
Success = false
};
success = await _xp.ToggleExcludeChannelAsync(request.GuildId, request.Id);
}
return new()
{
Success = success
};
}
public override async Task<DeleteExclusionReply> DeleteExclusion(
DeleteExclusionRequest request,
ServerCallContext context)
{
var success = false;
if (request.Type == "Role")
success = await _xp.ToggleExcludeRoleAsync(request.GuildId, request.Id);
else
success = await _xp.ToggleExcludeChannelAsync(request.GuildId, request.Id);
return new DeleteExclusionReply
{
Success = success
};
}
public override async Task<AddRewardReply> AddReward(AddRewardRequest request, ServerCallContext context)
{
await Task.Yield();
@ -267,15 +214,4 @@ public class XpSvc : GrpcXp.GrpcXpBase, IGrpcSvc, IEService
return reply;
}
public override async Task<SetServerExclusionReply> SetServerExclusion(
SetServerExclusionRequest request,
ServerCallContext context)
{
var newValue = await _xp.ToggleExcludeServerAsync(request.GuildId);
return new()
{
Success = newValue
};
}
}

View file

@ -40,7 +40,7 @@ public abstract class EllieInteractionBase
message = msg;
Client.InteractionCreated += OnInteraction;
await Task.WhenAny(Task.Delay(30_000), _interactionCompletedSource.Task);
await Task.WhenAny(Task.Delay(600_000), _interactionCompletedSource.Task);
Client.InteractionCreated -= OnInteraction;
if (_clearAfter)
@ -130,7 +130,7 @@ public sealed class EllieModalSubmitHandler
message = msg;
Client.ModalSubmitted += OnInteraction;
await Task.WhenAny(Task.Delay(300_000), _interactionCompletedSource.Task);
await Task.WhenAny(Task.Delay(600_000), _interactionCompletedSource.Task);
Client.ModalSubmitted -= OnInteraction;
await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build());

View file

@ -159,7 +159,7 @@ public partial class ResponseBuilder
await Task.WhenAll(left.RunAsync(msg), extra?.RunAsync(msg) ?? Task.CompletedTask, right.RunAsync(msg));
await Task.Delay(30_000);
await Task.Delay(600_000);
await msg.ModifyAsync(mp => mp.Components = new ComponentBuilder().Build());
}

View file

@ -1091,12 +1091,6 @@ experience:
xptemplatereload:
- xptempreload
- xptr
xpexclusionlist:
- xpexclusionlist
- xpexl
xpexclude:
- xpexclude
- xpex
xpleveluprewards:
- xplvluprewards
- xprews
@ -1581,4 +1575,8 @@ fishlist:
fishspot:
- fishspot
- fisp
- fish?
- fish?
xprate:
- xprate
xpratereset:
- xpratereset

View file

@ -3517,28 +3517,6 @@ xptemplatereload:
- ''
params:
- { }
xpexclusionlist:
desc: Shows the roles and channels excluded from the XP system on this server, as well as whether the whole server is excluded.
ex:
- ''
params:
- { }
xpexclude:
desc: Exclude a channel, role or current server from the xp system.
ex:
- Role Excluded-Role
- Server
params:
- _:
desc: "The ID of the server to exclude from the XP system."
- _:
desc: "The role that should not receive XP rewards."
role:
desc: "The role that should not receive XP rewards."
- _:
desc: "The ID of the channel to exclude from XP tracking."
channel:
desc: "The ID of the channel to exclude from XP tracking."
xpleveluprewards:
desc: Shows currently set level up rewards.
ex:
@ -4953,4 +4931,40 @@ fishspot:
ex:
- ''
params:
- { }
- { }
xprate:
desc: |-
Sets the xp rate for the server or the specified channel.
First specify the type, amount, and then the cooldown in minutes.
Provide no parameters to see the current rates.
Cooldown has no effect on voice xp, as any amount is gained per minute.
ex:
- ''
- 'text 3 5'
- '#channel voice 50 1'
params:
- { }
- type:
desc: "The type of rate to set. One of: text, voice or image."
amount:
desc: "The amount of xp to give per message."
minutes:
desc: "The cooldown in minutes. Allows decimal values."
- channel:
desc: "The channel to set the rate for."
type:
desc: "The type of rate to set. One of: text, voice or image."
amount:
desc: "The amount of xp to give per message."
minutes:
desc: "The cooldown in minutes. Allows decimal values."
xpratereset:
desc: |-
Resets the xp rate for the server or the specified channel.
ex:
- ''
- '#channel'
params:
- { }
- channel:
desc: "The channel to reset the rate for."

View file

@ -836,11 +836,6 @@
"xpn_notif_dm": "In a direct message channel.",
"xpn_notif_disabled": "Nowhere.",
"xprewsreset_confirm": "Are you sure you want to delete ALL xp level up rewards from this server? This action is irreversible.",
"excluded": "{0} has been excluded from the XP system on this server.",
"not_excluded": "{0} is no longer excluded from the XP system on this server.",
"exclusion_list": "Exclusion List",
"server_is_excluded": "This server is excluded.",
"server_is_not_excluded": "This server is not excluded.",
"level_up_channel": "Congratulations {0}, You've reached level {1}!",
"level_up_dm": "Congratulations {0}, You've reached level {1} on {2} server!",
"level_up_global": "Congratulations {0}, You've reached global level {1}!",
@ -1172,5 +1167,16 @@
"fish_weather_forecast": "Forecast",
"fish_tod": "Time of Day",
"fish_skill_up": "Fishing skill increased to **{0} / {1}**",
"fish_list_title": "Fishing"
"fish_list_title": "Fishing",
"xp_rate_none": "No xp rate overrides on this server.",
"xp_rate_amount_invalid": "Amount must be between 0 and 1000.",
"xp_rate_cooldown_invalid": "Cooldown must be between 0 and 1440.",
"xp_rate_server": "Server xp rates",
"xp_rate_str": "`{0}:` {1} xp every {2} min.",
"xp_rate_channels": "Channel xp rates",
"xp_rate_server_set": "Server xp rate set to **{0}** xp per every **{1}** min.",
"xp_rate_channel_set": "Channel **{0}** xp rate set to **{1}** xp per every **{2}** min.",
"xp_rate_server_reset": "Server xp rate has been reset to global defaults.",
"xp_rate_channel_reset": "Channel {0} xp rate has been reset.",
"xp_rate_no_gain": "No xp gain"
}