Compare commits

...

10 commits
6.0.2 ... 6.0.3

48 changed files with 2076 additions and 473 deletions

6
.gitignore vendored
View file

@ -372,3 +372,9 @@ site/
.aider.*
PROMPT.md
.aider*
.windsurfrules
## Python pip/env files
Pipfile
Pipfile.lock
.venv

View file

@ -1,6 +1,54 @@
# Changelog
Mostly based on [keepachangelog](https://keepachangelog.com/en/1.1.0/) except date format. a-c-f-r-o
*a,c,f,r,o*
## [6.0.3] - [12.03.2025]
### Added
- `.xp` system reworked
- Global XP has been removed in favor of server XP
- You can now set `.xprate` for each channel in your server!
- You can set voice, image, and text rates
- Use `.xpratereset` to reset it back to default
- This feature makes `.xpexclude` obsolete
- Requirement to create a club removed
- `.xp` card should generate faster
- Fixed countless possible issues with xp where some users didn't gain xp, or froze, etc
- user-role commands added!
- `.ura <user> <role>` - assign a role to a user
- `.url <user?>` - list assigned roles for all users or a specific user
- `.urm` - show 'my' (your) assigned roles
- `.urn <role> <new_name>` - set a name for your role
- `.urc <role> <hex_color>` - set a color for your role
- `.uri <role> <url/server_emoji>` - set an icon for your role (accepts either a server emoji or a link to an image)
- `.notify` improved
- Lets you specify source channel (for some events) as the message output
- `.pload <id> --shuffle` lets you load a saved playlist in random order
- `.lyrics <song_name>` added - find lyrics for a song (it's not always accurate)
- For Selfhosters
- you have to update to latest v5 before updating to v6, otherwise migrations will fail
- migration system was reworked
- Xp card is now 500x245
- xp_template.json backed up to old_xp_template.json
- check pinned message in #dev channel to see full selfhoster announcement
- Get bot version via --version
### Changed
- `.lopl` will queue subdirectories too now
- Some music playlist commands have been renamed to fit with other commands
- Removed gold/silver frames from xp card
- `.inrole` is now showing users in alphabetical order
- `.curtrs` are now paginated
- pagination now lasts for 10+ minutes
- selfhosters: Restart command default now assumes binary installation
### Removed
- Removed several fonts
- Xp Exclusion commands (superseded by `.xprate`)
## [5.3.9] - 31.01.2025

View file

@ -7,14 +7,23 @@ namespace EllieBot.Db;
public static class CurrencyTransactionExtensions
{
public static Task<List<CurrencyTransaction>> GetPageFor(
public static async Task<IReadOnlyCollection<CurrencyTransaction>> GetPageFor(
this DbSet<CurrencyTransaction> set,
ulong userId,
int page)
=> set.ToLinqToDBTable()
{
var items = await set.ToLinqToDBTable()
.Where(x => x.UserId == userId)
.OrderByDescending(x => x.DateAdded)
.Skip(15 * page)
.Take(15)
.ToListAsyncLinqToDB();
return items;
}
public static async Task<int> GetCountFor(this DbSet<CurrencyTransaction> set, ulong userId)
=> await set.ToLinqToDBTable()
.Where(x => x.UserId == userId)
.CountAsyncLinqToDB();
}

View file

@ -9,6 +9,7 @@ public class ClubInfo : DbEntity
public string Name { get; set; }
public string Description { get; set; }
public string ImageUrl { get; set; } = string.Empty;
public string BannerUrl { get; set; } = string.Empty;
public int Xp { get; set; } = 0;
public int? OwnerId { get; set; }

View file

@ -4,7 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<Version>6.0.2</Version>
<Version>6.0.3</Version>
<!-- Output/build -->
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>
@ -61,7 +61,7 @@
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
<PackageReference Include="SixLabors.Fonts" Version="2.1.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.5" />
<PackageReference Include="SixLabors.Shapes" Version="1.0.0-beta0009" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />

View file

@ -0,0 +1,16 @@
START TRANSACTION;
CREATE TABLE userrole (
guildid numeric(20,0) NOT NULL,
userid numeric(20,0) NOT NULL,
roleid numeric(20,0) NOT NULL,
CONSTRAINT pk_userrole PRIMARY KEY (guildid, userid, roleid)
);
CREATE INDEX ix_userrole_guildid ON userrole (guildid);
CREATE INDEX ix_userrole_guildid_userid ON userrole (guildid, userid);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250310101121_userroles', '9.0.1');
COMMIT;

View file

@ -0,0 +1,7 @@
START TRANSACTION;
ALTER TABLE clubs ADD bannerurl text;
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250310143026_club-banners', '9.0.1');
COMMIT;

View file

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace EllieBot.Migrations.PostgreSql
{
[DbContext(typeof(PostgreSqlContext))]
[Migration("20250228044209_init")]
[Migration("20250310143051_init")]
partial class init
{
/// <inheritdoc />
@ -572,6 +572,10 @@ namespace EllieBot.Migrations.PostgreSql
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("BannerUrl")
.HasColumnType("text")
.HasColumnName("bannerurl");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
@ -3444,6 +3448,32 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("userfishstats", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Utility.UserRole.UserRole", b =>
{
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.Property<decimal>("RoleId")
.HasColumnType("numeric(20,0)")
.HasColumnName("roleid");
b.HasKey("GuildId", "UserId", "RoleId")
.HasName("pk_userrole");
b.HasIndex("GuildId")
.HasDatabaseName("ix_userrole_guildid");
b.HasIndex("GuildId", "UserId")
.HasDatabaseName("ix_userrole_guildid_userid");
b.ToTable("userrole", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
{
b.Property<int>("Id")

View file

@ -1078,6 +1078,19 @@ namespace EllieBot.Migrations.PostgreSql
table.PrimaryKey("pk_userfishstats", x => x.id);
});
migrationBuilder.CreateTable(
name: "userrole",
columns: table => new
{
guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
userid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
roleid = table.Column<decimal>(type: "numeric(20,0)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_userrole", x => new { x.guildid, x.userid, x.roleid });
});
migrationBuilder.CreateTable(
name: "userxpstats",
columns: table => new
@ -1588,6 +1601,7 @@ namespace EllieBot.Migrations.PostgreSql
name = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
description = table.Column<string>(type: "text", nullable: true),
imageurl = table.Column<string>(type: "text", nullable: true),
bannerurl = table.Column<string>(type: "text", nullable: true),
xp = table.Column<int>(type: "integer", nullable: false),
ownerid = table.Column<int>(type: "integer", nullable: true),
dateadded = table.Column<DateTime>(type: "timestamp without time zone", nullable: true)
@ -2131,6 +2145,16 @@ namespace EllieBot.Migrations.PostgreSql
column: "userid",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_userrole_guildid",
table: "userrole",
column: "guildid");
migrationBuilder.CreateIndex(
name: "ix_userrole_guildid_userid",
table: "userrole",
columns: new[] { "guildid", "userid" });
migrationBuilder.CreateIndex(
name: "ix_userxpstats_guildid",
table: "userxpstats",
@ -2492,6 +2516,9 @@ namespace EllieBot.Migrations.PostgreSql
migrationBuilder.DropTable(
name: "userfishstats");
migrationBuilder.DropTable(
name: "userrole");
migrationBuilder.DropTable(
name: "userxpstats");

View file

@ -569,6 +569,10 @@ namespace EllieBot.Migrations.PostgreSql
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("BannerUrl")
.HasColumnType("text")
.HasColumnName("bannerurl");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
@ -3441,6 +3445,32 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("userfishstats", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Utility.UserRole.UserRole", b =>
{
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.Property<decimal>("RoleId")
.HasColumnType("numeric(20,0)")
.HasColumnName("roleid");
b.HasKey("GuildId", "UserId", "RoleId")
.HasName("pk_userrole");
b.HasIndex("GuildId")
.HasDatabaseName("ix_userrole_guildid");
b.HasIndex("GuildId", "UserId")
.HasDatabaseName("ix_userrole_guildid_userid");
b.ToTable("userrole", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
{
b.Property<int>("Id")

View file

@ -0,0 +1,16 @@
BEGIN TRANSACTION;
CREATE TABLE "UserRole" (
"GuildId" INTEGER NOT NULL,
"UserId" INTEGER NOT NULL,
"RoleId" INTEGER NOT NULL,
CONSTRAINT "PK_UserRole" PRIMARY KEY ("GuildId", "UserId", "RoleId")
);
CREATE INDEX "IX_UserRole_GuildId" ON "UserRole" ("GuildId");
CREATE INDEX "IX_UserRole_GuildId_UserId" ON "UserRole" ("GuildId", "UserId");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250310101118_userroles', '9.0.1');
COMMIT;

View file

@ -0,0 +1,7 @@
BEGIN TRANSACTION;
ALTER TABLE "Clubs" ADD "BannerUrl" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250310143023_club-banners', '9.0.1');
COMMIT;

View file

@ -11,7 +11,7 @@ using EllieBot.Db;
namespace EllieBot.Migrations.Sqlite
{
[DbContext(typeof(SqliteContext))]
[Migration("20250228044206_init")]
[Migration("20250310143048_init")]
partial class init
{
/// <inheritdoc />
@ -428,6 +428,9 @@ namespace EllieBot.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BannerUrl")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
@ -2563,6 +2566,26 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("UserFishStats");
});
modelBuilder.Entity("EllieBot.Modules.Utility.UserRole.UserRole", b =>
{
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.Property<ulong>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("GuildId", "UserId", "RoleId");
b.HasIndex("GuildId");
b.HasIndex("GuildId", "UserId");
b.ToTable("UserRole");
});
modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
{
b.Property<int>("Id")

View file

@ -1080,6 +1080,19 @@ namespace EllieBot.Migrations.Sqlite
table.PrimaryKey("PK_UserFishStats", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserRole",
columns: table => new
{
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
RoleId = table.Column<ulong>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRole", x => new { x.GuildId, x.UserId, x.RoleId });
});
migrationBuilder.CreateTable(
name: "UserXpStats",
columns: table => new
@ -1590,6 +1603,7 @@ namespace EllieBot.Migrations.Sqlite
Name = table.Column<string>(type: "TEXT", maxLength: 20, nullable: true),
Description = table.Column<string>(type: "TEXT", nullable: true),
ImageUrl = table.Column<string>(type: "TEXT", nullable: true),
BannerUrl = table.Column<string>(type: "TEXT", nullable: true),
Xp = table.Column<int>(type: "INTEGER", nullable: false),
OwnerId = table.Column<int>(type: "INTEGER", nullable: true),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true)
@ -2133,6 +2147,16 @@ namespace EllieBot.Migrations.Sqlite
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserRole_GuildId",
table: "UserRole",
column: "GuildId");
migrationBuilder.CreateIndex(
name: "IX_UserRole_GuildId_UserId",
table: "UserRole",
columns: new[] { "GuildId", "UserId" });
migrationBuilder.CreateIndex(
name: "IX_UserXpStats_GuildId",
table: "UserXpStats",
@ -2494,6 +2518,9 @@ namespace EllieBot.Migrations.Sqlite
migrationBuilder.DropTable(
name: "UserFishStats");
migrationBuilder.DropTable(
name: "UserRole");
migrationBuilder.DropTable(
name: "UserXpStats");

View file

@ -425,6 +425,9 @@ namespace EllieBot.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BannerUrl")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
@ -2560,6 +2563,26 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("UserFishStats");
});
modelBuilder.Entity("EllieBot.Modules.Utility.UserRole.UserRole", b =>
{
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.Property<ulong>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("GuildId", "UserId", "RoleId");
b.HasIndex("GuildId");
b.HasIndex("GuildId", "UserId");
b.ToTable("UserRole");
});
modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
{
b.Property<int>("Id")

View file

@ -218,20 +218,20 @@ public partial class Gambling : GamblingModule<GamblingService>
var val = Config.Timely.Amount;
var boostGuilds = Config.BoostBonus.GuildIds ?? new();
var guildUsers = await boostGuilds
.Select(async gid =>
{
try
{
var guild = await _client.Rest.GetGuildAsync(gid, false);
var user = await _client.Rest.GetGuildUserAsync(gid, ctx.User.Id);
return (guild, user);
}
catch
{
return default;
}
})
.WhenAll();
.Select(async gid =>
{
try
{
var guild = await _client.Rest.GetGuildAsync(gid, false);
var user = await _client.Rest.GetGuildUserAsync(gid, ctx.User.Id);
return (guild, user);
}
catch
{
return default;
}
})
.WhenAll();
var userInfo = guildUsers.FirstOrDefault(x => x.user?.PremiumSince is not null);
var booster = userInfo != default;
@ -296,8 +296,8 @@ public partial class Gambling : GamblingModule<GamblingService>
else
{
await Response()
.Confirm(strs.timely_set(Format.Bold(N(amount)), Format.Bold(period.ToString())))
.SendAsync();
.Confirm(strs.timely_set(Format.Bold(N(amount)), Format.Bold(period.ToString())))
.SendAsync();
}
}
@ -316,10 +316,10 @@ public partial class Gambling : GamblingModule<GamblingService>
var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)];
await Response()
.Confirm("🎟 " + GetText(strs.raffled_user),
$"**{usr.Username}**",
footer: $"ID: {usr.Id}")
.SendAsync();
.Confirm("🎟 " + GetText(strs.raffled_user),
$"**{usr.Username}**",
footer: $"ID: {usr.Id}")
.SendAsync();
}
[Cmd]
@ -337,10 +337,10 @@ public partial class Gambling : GamblingModule<GamblingService>
var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)];
await Response()
.Confirm("🎟 " + GetText(strs.raffled_user),
$"**{usr.Username}**",
footer: $"ID: {usr.Id}")
.SendAsync();
.Confirm("🎟 " + GetText(strs.raffled_user),
$"**{usr.Username}**",
footer: $"ID: {usr.Id}")
.SendAsync();
}
[Cmd]
@ -373,41 +373,54 @@ public partial class Gambling : GamblingModule<GamblingService>
return;
}
List<CurrencyTransaction> trs;
var embed = CreateEmbed()
.WithTitle(GetText(strs.transactions(
((SocketGuild)ctx.Guild)?.GetUser(userId)?.ToString()
?? $"{userId}")))
.WithOkColor();
int count;
await using (var uow = _db.GetDbContext())
{
trs = await uow.Set<CurrencyTransaction>().GetPageFor(userId, page);
count = await uow.Set<CurrencyTransaction>()
.GetCountFor(userId);
}
var embed = CreateEmbed()
.WithTitle(GetText(strs.transactions(
((SocketGuild)ctx.Guild)?.GetUser(userId)?.ToString()
?? $"{userId}")))
.WithOkColor();
var sb = new StringBuilder();
foreach (var tr in trs)
{
var change = tr.Amount >= 0 ? "🔵" : "🔴";
var kwumId = new kwum(tr.Id).ToString();
var date = $"#{Format.Code(kwumId)} `〖{GetFormattedCurtrDate(tr)}〗`";
sb.AppendLine($"\\{change} {date} {Format.Bold(N(tr.Amount))}");
var transactionString = GetHumanReadableTransaction(tr.Type, tr.Extra, tr.OtherId);
if (transactionString is not null)
await Response()
.Paginated()
.PageItems(async (curPage) =>
{
sb.AppendLine(transactionString);
}
if (!string.IsNullOrWhiteSpace(tr.Note))
await using var uow = _db.GetDbContext();
return await uow.Set<CurrencyTransaction>()
.GetPageFor(userId, curPage);
})
.PageSize(15)
.TotalElements(count)
.Page((trs, _) =>
{
sb.AppendLine($"\t`Note:` {tr.Note.TrimTo(50)}");
}
}
var sb = new StringBuilder();
foreach (var tr in trs)
{
var change = tr.Amount >= 0 ? "🔵" : "🔴";
var kwumId = new kwum(tr.Id).ToString();
var date = $"#{Format.Code(kwumId)} `〖{GetFormattedCurtrDate(tr)}〗`";
embed.WithDescription(sb.ToString());
embed.WithFooter(GetText(strs.page(page + 1)));
await Response().Embed(embed).SendAsync();
sb.AppendLine($"\\{change} {date} {Format.Bold(N(tr.Amount))}");
var transactionString = GetHumanReadableTransaction(tr.Type, tr.Extra, tr.OtherId);
if (transactionString is not null)
{
sb.AppendLine(transactionString);
}
if (!string.IsNullOrWhiteSpace(tr.Note))
{
sb.AppendLine($"\t`Note:` {tr.Note.TrimTo(50)}");
}
}
embed.WithDescription(sb.ToString());
return Task.FromResult(embed);
}).SendAsync();
}
private static string GetFormattedCurtrDate(CurrencyTransaction ct)
@ -420,9 +433,9 @@ public partial class Gambling : GamblingModule<GamblingService>
await using var uow = _db.GetDbContext();
var tr = await uow.Set<CurrencyTransaction>()
.ToLinqToDBTable()
.Where(x => x.Id == intId && x.UserId == ctx.User.Id)
.FirstOrDefaultAsync();
.ToLinqToDBTable()
.Where(x => x.Id == intId && x.UserId == ctx.User.Id)
.FirstOrDefaultAsync();
if (tr is null)
{
@ -483,9 +496,9 @@ public partial class Gambling : GamblingModule<GamblingService>
var balance = await _bank.GetBalanceAsync(ctx.User.Id);
await N(balance)
.Pipe(strs.bank_balance)
.Pipe(GetText)
.Pipe(text => smc.RespondConfirmAsync(_sender, text, ephemeral: true));
.Pipe(strs.bank_balance)
.Pipe(GetText)
.Pipe(text => smc.RespondConfirmAsync(_sender, text, ephemeral: true));
}
private EllieInteractionBase CreateCashInteraction()
@ -507,13 +520,13 @@ public partial class Gambling : GamblingModule<GamblingService>
: null;
await Response()
.Confirm(
user.ToString()
.Pipe(Format.Bold)
.With(cur)
.Pipe(strs.has))
.Interaction(inter)
.SendAsync();
.Confirm(
user.ToString()
.Pipe(Format.Bold)
.With(cur)
.Pipe(strs.has))
.Interaction(inter)
.SendAsync();
}
[Cmd]
@ -594,10 +607,10 @@ public partial class Gambling : GamblingModule<GamblingService>
new("award", ctx.User.ToString()!, role.Name, ctx.User.Id));
await Response()
.Confirm(strs.mass_award(N(amount),
Format.Bold(users.Count.ToString()),
Format.Bold(role.Name)))
.SendAsync();
.Confirm(strs.mass_award(N(amount),
Format.Bold(users.Count.ToString()),
Format.Bold(role.Name)))
.SendAsync();
}
[Cmd]
@ -613,10 +626,10 @@ public partial class Gambling : GamblingModule<GamblingService>
new("take", ctx.User.ToString()!, null, ctx.User.Id));
await Response()
.Confirm(strs.mass_take(N(amount),
Format.Bold(users.Count.ToString()),
Format.Bold(role.Name)))
.SendAsync();
.Confirm(strs.mass_take(N(amount),
Format.Bold(users.Count.ToString()),
Format.Bold(role.Name)))
.SendAsync();
}
[Cmd]
@ -639,8 +652,8 @@ public partial class Gambling : GamblingModule<GamblingService>
else
{
await Response()
.Error(strs.take_fail(N(amount), Format.Bold(user.ToString()), CurrencySign))
.SendAsync();
.Error(strs.take_fail(N(amount), Format.Bold(user.ToString()), CurrencySign))
.SendAsync();
}
}
@ -662,8 +675,8 @@ public partial class Gambling : GamblingModule<GamblingService>
else
{
await Response()
.Error(strs.take_fail(N(amount), Format.Code(usrId.ToString()), CurrencySign))
.SendAsync();
.Error(strs.take_fail(N(amount), Format.Code(usrId.ToString()), CurrencySign))
.SendAsync();
}
}
@ -695,12 +708,12 @@ public partial class Gambling : GamblingModule<GamblingService>
}
var eb = CreateEmbed()
.WithAuthor(ctx.User)
.WithDescription(Format.Bold(str))
.AddField(GetText(strs.roll2), result.Roll.ToString(CultureInfo.InvariantCulture), true)
.AddField(GetText(strs.bet), N(amount), true)
.AddField(GetText(strs.won), N((long)result.Won), true)
.WithOkColor();
.WithAuthor(ctx.User)
.WithDescription(Format.Bold(str))
.AddField(GetText(strs.roll2), result.Roll.ToString(CultureInfo.InvariantCulture), true)
.AddField(GetText(strs.bet), N(amount), true)
.AddField(GetText(strs.won), N((long)result.Won), true)
.WithOkColor();
await Response().Embed(eb).SendAsync();
}
@ -741,11 +754,11 @@ public partial class Gambling : GamblingModule<GamblingService>
await using var uow = _db.GetDbContext();
var cleanRichest = await uow.GetTable<DiscordUser>()
.Where(x => x.UserId.In(users))
.OrderByDescending(x => x.CurrencyAmount)
.Skip(curPage * perPage)
.Take(perPage)
.ToListAsync();
.Where(x => x.UserId.In(users))
.OrderByDescending(x => x.CurrencyAmount)
.Skip(curPage * perPage)
.Take(perPage)
.ToListAsync();
return cleanRichest;
}
@ -757,34 +770,34 @@ public partial class Gambling : GamblingModule<GamblingService>
}
await Response()
.Paginated()
.PageItems(GetTopRichest)
.PageSize(9)
.CurrentPage(page)
.Page((toSend, curPage) =>
{
var embed = CreateEmbed()
.WithOkColor()
.WithTitle(CurrencySign + " " + GetText(strs.leaderboard));
.Paginated()
.PageItems(GetTopRichest)
.PageSize(9)
.CurrentPage(page)
.Page((toSend, curPage) =>
{
var embed = CreateEmbed()
.WithOkColor()
.WithTitle(CurrencySign + " " + GetText(strs.leaderboard));
if (!toSend.Any())
{
embed.WithDescription(GetText(strs.no_user_on_this_page));
return Task.FromResult(embed);
}
if (!toSend.Any())
{
embed.WithDescription(GetText(strs.no_user_on_this_page));
return Task.FromResult(embed);
}
for (var i = 0; i < toSend.Count; i++)
{
var x = toSend[i];
var usrStr = x.ToString().TrimTo(20, true);
for (var i = 0; i < toSend.Count; i++)
{
var x = toSend[i];
var usrStr = x.ToString().TrimTo(20, true);
var j = i;
embed.AddField("#" + ((9 * curPage) + j + 1) + " " + usrStr, N(x.CurrencyAmount), true);
}
var j = i;
embed.AddField("#" + ((9 * curPage) + j + 1) + " " + usrStr, N(x.CurrencyAmount), true);
}
return Task.FromResult(embed);
})
.SendAsync();
return Task.FromResult(embed);
})
.SendAsync();
}
public enum InputRpsPick : byte
@ -895,11 +908,11 @@ public partial class Gambling : GamblingModule<GamblingService>
}
var eb = CreateEmbed()
.WithOkColor()
.WithDescription(sb.ToString())
.AddField(GetText(strs.bet), N(amount), true)
.AddField(GetText(strs.won), $"{N((long)result.Won)}", true)
.WithAuthor(ctx.User);
.WithOkColor()
.WithDescription(sb.ToString())
.AddField(GetText(strs.bet), N(amount), true)
.AddField(GetText(strs.won), $"{N((long)result.Won)}", true)
.WithAuthor(ctx.User);
await Response().Embed(eb).SendAsync();
@ -924,8 +937,8 @@ public partial class Gambling : GamblingModule<GamblingService>
public async Task BetTest()
{
var values = Enum.GetValues<GambleTestTarget>()
.Select(x => $"`{x}`")
.Join(", ");
.Select(x => $"`{x}`")
.Join(", ");
await Response().Confirm(GetText(strs.available_tests), values).SendAsync();
}
@ -998,10 +1011,10 @@ public partial class Gambling : GamblingModule<GamblingService>
sb.AppendLine($"Longest lose streak: `{maxL}`");
await Response()
.Confirm(GetText(strs.test_results_for(target)),
sb.ToString(),
footer: $"Total Bet: {tests} | Payout: {payout:F0} | {payout * 1.0M / tests * 100}%")
.SendAsync();
.Confirm(GetText(strs.test_results_for(target)),
sb.ToString(),
footer: $"Total Bet: {tests} | Payout: {payout:F0} | {payout * 1.0M / tests * 100}%")
.SendAsync();
}
private EllieInteractionBase CreateRakebackInteraction()
@ -1032,16 +1045,16 @@ public partial class Gambling : GamblingModule<GamblingService>
if (rb < 1)
{
await Response()
.Error(strs.rakeback_none)
.SendAsync();
.Error(strs.rakeback_none)
.SendAsync();
return;
}
var inter = CreateRakebackInteraction();
await Response()
.Pending(strs.rakeback_available(N(rb)))
.Interaction(inter)
.SendAsync();
.Pending(strs.rakeback_available(N(rb)))
.Interaction(inter)
.SendAsync();
}
}

View file

@ -7,13 +7,24 @@ namespace EllieBot.Modules.Music;
[NoPublicBot]
public sealed partial class Music : EllieModule<IMusicService>
{
public enum All { All = -1 }
public enum All
{
All = -1
}
public enum InputRepeatType
{
N = 0, No = 0, None = 0,
T = 1, Track = 1, S = 1, Song = 1,
Q = 2, Queue = 2, Playlist = 2, Pl = 2
N = 0,
No = 0,
None = 0,
T = 1,
Track = 1,
S = 1,
Song = 1,
Q = 2,
Queue = 2,
Playlist = 2,
Pl = 2
}
public const string MUSIC_ICON_URL = "https://i.imgur.com/nhKS3PT.png";
@ -22,9 +33,13 @@ public sealed partial class Music : EllieModule<IMusicService>
private static readonly SemaphoreSlim _voiceChannelLock = new(1, 1);
private readonly ILogCommandService _logService;
private readonly ILyricsService _lyricsService;
public Music(ILogCommandService logService)
=> _logService = logService;
public Music(ILogCommandService logService, ILyricsService lyricsService)
{
_logService = logService;
_lyricsService = lyricsService;
}
private async Task<bool> ValidateAsync()
{
@ -110,10 +125,10 @@ public sealed partial class Music : EllieModule<IMusicService>
try
{
var embed = CreateEmbed()
.WithOkColor()
.WithAuthor(GetText(strs.queued_track) + " #" + (index + 1), MUSIC_ICON_URL)
.WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ")
.WithFooter(trackInfo.Platform.ToString());
.WithOkColor()
.WithAuthor(GetText(strs.queued_track) + " #" + (index + 1), MUSIC_ICON_URL)
.WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ")
.WithFooter(trackInfo.Platform.ToString());
if (!string.IsNullOrWhiteSpace(trackInfo.Thumbnail))
embed.WithThumbnailUrl(trackInfo.Thumbnail);
@ -301,39 +316,39 @@ public sealed partial class Music : EllieModule<IMusicService>
desc += tracks
.Select((v, index) =>
{
index += LQ_ITEMS_PER_PAGE * curPage;
if (index == currentIndex)
return $"**⇒**`{index + 1}.` {v.PrettyFullName()}";
.Select((v, index) =>
{
index += LQ_ITEMS_PER_PAGE * curPage;
if (index == currentIndex)
return $"**⇒**`{index + 1}.` {v.PrettyFullName()}";
return $"`{index + 1}.` {v.PrettyFullName()}";
})
.Join('\n');
return $"`{index + 1}.` {v.PrettyFullName()}";
})
.Join('\n');
if (!string.IsNullOrWhiteSpace(add))
desc = add + "\n" + desc;
var embed = CreateEmbed()
.WithAuthor(
GetText(strs.player_queue(curPage + 1, (tracks.Count / LQ_ITEMS_PER_PAGE) + 1)),
MUSIC_ICON_URL)
.WithDescription(desc)
.WithFooter(
$" {mp.PrettyVolume()} | 🎶 {tracks.Count} | ⌛ {mp.PrettyTotalTime()} ")
.WithOkColor();
.WithAuthor(
GetText(strs.player_queue(curPage + 1, (tracks.Count / LQ_ITEMS_PER_PAGE) + 1)),
MUSIC_ICON_URL)
.WithDescription(desc)
.WithFooter(
$" {mp.PrettyVolume()} | 🎶 {tracks.Count} | ⌛ {mp.PrettyTotalTime()} ")
.WithOkColor();
return embed;
}
await Response()
.Paginated()
.Items(tracks)
.PageSize(LQ_ITEMS_PER_PAGE)
.CurrentPage(page)
.AddFooter(false)
.Page(PrintAction)
.SendAsync();
.Paginated()
.Items(tracks)
.PageSize(LQ_ITEMS_PER_PAGE)
.CurrentPage(page)
.AddFooter(false)
.Page(PrintAction)
.SendAsync();
}
// search
@ -353,15 +368,15 @@ public sealed partial class Music : EllieModule<IMusicService>
var embeds = videos.Select((x, i) => CreateEmbed()
.WithOkColor()
.WithThumbnailUrl(x.Thumbnail)
.WithDescription($"`{i + 1}.` {Format.Bold(x.Title)}\n\t{x.Url}"))
.ToList();
.WithOkColor()
.WithThumbnailUrl(x.Thumbnail)
.WithDescription($"`{i + 1}.` {Format.Bold(x.Title)}\n\t{x.Url}"))
.ToList();
var msg = await Response()
.Text(strs.queue_search_results)
.Embeds(embeds)
.SendAsync();
.Text(strs.queue_search_results)
.Embeds(embeds)
.SendAsync();
try
{
@ -425,10 +440,10 @@ public sealed partial class Music : EllieModule<IMusicService>
}
var embed = CreateEmbed()
.WithAuthor(GetText(strs.removed_track) + " #" + index, MUSIC_ICON_URL)
.WithDescription(track.PrettyName())
.WithFooter(track.PrettyInfo())
.WithErrorColor();
.WithAuthor(GetText(strs.removed_track) + " #" + index, MUSIC_ICON_URL)
.WithDescription(track.PrettyName())
.WithFooter(track.PrettyInfo())
.WithErrorColor();
await _service.SendToOutputAsync(ctx.Guild.Id, embed);
}
@ -593,11 +608,11 @@ public sealed partial class Music : EllieModule<IMusicService>
}
var embed = CreateEmbed()
.WithTitle(track.Title.TrimTo(65))
.WithAuthor(GetText(strs.track_moved), MUSIC_ICON_URL)
.AddField(GetText(strs.from_position), $"#{from + 1}", true)
.AddField(GetText(strs.to_position), $"#{to + 1}", true)
.WithOkColor();
.WithTitle(track.Title.TrimTo(65))
.WithAuthor(GetText(strs.track_moved), MUSIC_ICON_URL)
.AddField(GetText(strs.from_position), $"#{from + 1}", true)
.AddField(GetText(strs.to_position), $"#{to + 1}", true)
.WithOkColor();
if (Uri.IsWellFormedUriString(track.Url, UriKind.Absolute))
embed.WithUrl(track.Url);
@ -652,12 +667,12 @@ public sealed partial class Music : EllieModule<IMusicService>
return;
var embed = CreateEmbed()
.WithOkColor()
.WithAuthor(GetText(strs.now_playing), MUSIC_ICON_URL)
.WithDescription(currentTrack.PrettyName())
.WithThumbnailUrl(currentTrack.Thumbnail)
.WithFooter(
$"{mp.PrettyVolume()} | {mp.PrettyTotalTime()} | {currentTrack.Platform} | {currentTrack.Queuer}");
.WithOkColor()
.WithAuthor(GetText(strs.now_playing), MUSIC_ICON_URL)
.WithDescription(currentTrack.PrettyName())
.WithThumbnailUrl(currentTrack.Thumbnail)
.WithFooter(
$"{mp.PrettyVolume()} | {mp.PrettyTotalTime()} | {currentTrack.Platform} | {currentTrack.Queuer}");
await Response().Embed(embed).SendAsync();
}
@ -768,4 +783,71 @@ public sealed partial class Music : EllieModule<IMusicService>
await Response().Confirm(strs.wrongsong_success(removed.Title.TrimTo(30))).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Lyrics([Leftover] string name = null)
{
if (string.IsNullOrWhiteSpace(name))
{
if (_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)
&& mp.GetCurrentTrack(out _) is { } currentTrack)
{
name = currentTrack.Title;
}
else
{
return;
}
}
var tracks = await _lyricsService.SearchTracksAsync(name);
if (tracks.Count == 0)
{
await Response().Error(strs.no_lyrics_found).SendAsync();
return;
}
var embed = CreateEmbed()
.WithFooter("type 1-5 to select");
for (var i = 0; i <= 5 && i < tracks.Count; i++)
{
var item = tracks[i];
embed.AddField($"`{(i + 1)}`. {item.Author}", item.Title, false);
}
await Response()
.Embed(embed)
.SendAsync();
var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id, str => int.TryParse(str, out _));
if (input is null)
return;
var index = int.Parse(input) - 1;
if (index < 0 || index > 4)
{
return;
}
var track = tracks[index];
var lyrics = await _lyricsService.GetLyricsAsync(track.Id);
if (string.IsNullOrWhiteSpace(lyrics))
{
await Response().Error(strs.no_lyrics_found).SendAsync();
return;
}
await Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithAuthor(track.Author)
.WithTitle(track.Title)
.WithDescription(lyrics))
.SendAsync();
}
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Modules.Music;
public interface ILyricsService
{
public Task<IReadOnlyList<TracksItem>> SearchTracksAsync(string name);
public Task<string> GetLyricsAsync(int trackId);
}

View file

@ -0,0 +1,25 @@
using Musix;
namespace EllieBot.Modules.Music;
public sealed class LyricsService(HttpClient client) : ILyricsService, IEService
{
private readonly MusixMatchAPI _api = new(client);
private static string NormalizeName(string name)
=> string.Join("-", name.Split()
.Select(x => new string(x.Where(c => char.IsLetterOrDigit(c)).ToArray())))
.Trim('-');
public async Task<IReadOnlyList<TracksItem>> SearchTracksAsync(string name)
=> await _api.SearchTracksAsync(NormalizeName(name))
.Fmap(x => x
.Message
.Body
.TrackList
.Map(x => new TracksItem(x.Track.ArtistName, x.Track.TrackName, x.Track.TrackId)));
public async Task<string> GetLyricsAsync(int trackId)
=> await _api.GetTrackLyricsAsync(trackId)
.Fmap(x => x.Message.Body.Lyrics.LyricsBody);
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class Header
{
[JsonPropertyName("status_code")]
public int StatusCode { get; set; }
[JsonPropertyName("execute_time")]
public double ExecuteTime { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class Lyrics
{
[JsonPropertyName("lyrics_body")]
public string LyricsBody { get; set; } = string.Empty;
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class LyricsResponse
{
[JsonPropertyName("lyrics")]
public Lyrics Lyrics { get; set; } = null!;
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class Message<T>
{
[JsonPropertyName("header")]
public Header Header { get; set; } = null!;
[JsonPropertyName("body")]
public T Body { get; set; } = default!;
}

View file

@ -0,0 +1,141 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Web;
using Microsoft.Extensions.Caching.Memory;
using Musix.Models;
// All credit goes to https://github.com/Strvm/musicxmatch-api for the original implementation
namespace Musix
{
public sealed class MusixMatchAPI
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl = "https://www.musixmatch.com/ws/1.1/";
private readonly string _userAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36";
private readonly IMemoryCache _cache;
private readonly JsonSerializerOptions _jsonOptions;
public MusixMatchAPI(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
_httpClient.DefaultRequestHeaders.Add("Cookie", "mxm_bab=AB");
_jsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
_cache = new MemoryCache(new MemoryCacheOptions { });
}
private async Task<string> GetLatestAppUrlAsync()
{
var url = "https://www.musixmatch.com/search";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
request.Headers.Add("Cookie", "mxm_bab=AB");
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var htmlContent = await response.Content.ReadAsStringAsync();
var pattern = @"src=""([^""]*/_next/static/chunks/pages/_app-[^""]+\.js)""";
var matches = Regex.Matches(htmlContent, pattern);
return matches.Count > 0
? matches[^1].Groups[1].Value
: throw new("_app URL not found in the HTML content.");
}
private async Task<string> GetSecret()
{
var latestAppUrl = await GetLatestAppUrlAsync();
var response = await _httpClient.GetAsync(latestAppUrl);
response.EnsureSuccessStatusCode();
var javascriptCode = await response.Content.ReadAsStringAsync();
var pattern = @"from\(\s*""(.*?)""\s*\.split";
var match = Regex.Match(javascriptCode, pattern);
if (match.Success)
{
var encodedString = match.Groups[1].Value;
var reversedString = new string(encodedString.Reverse().ToArray());
var decodedBytes = Convert.FromBase64String(reversedString);
return Encoding.UTF8.GetString(decodedBytes);
}
throw new Exception("Encoded string not found in the JavaScript code.");
}
// It seems this is required in order to have multiword queries.
// Spaces don't work in the original implementation either
private string UrlEncode(string value)
=> HttpUtility.UrlEncode(value)
.Replace("+", "-");
private async Task<string> GenerateSignature(string url)
{
var currentDate = DateTime.Now;
var l = currentDate.Year.ToString();
var s = currentDate.Month.ToString("D2");
var r = currentDate.Day.ToString("D2");
var message = (url + l + s + r);
var secret = await _cache.GetOrCreateAsync("secret", async _ => await GetSecret());
var key = Encoding.UTF8.GetBytes(secret ?? string.Empty);
var messageBytes = Encoding.UTF8.GetBytes(message);
using var hmac = new HMACSHA256(key);
var hashBytes = hmac.ComputeHash(messageBytes);
var signature = Convert.ToBase64String(hashBytes);
return $"&signature={UrlEncode(signature)}&signature_protocol=sha256";
}
public async Task<MusixMatchResponse<TrackSearchResponse>> SearchTracksAsync(string trackQuery, int page = 1)
{
var endpoint =
$"track.search?app_id=community-app-v1.0&format=json&q={UrlEncode(trackQuery)}&f_has_lyrics=true&page_size=100&page={page}";
var jsonResponse = await MakeRequestAsync(endpoint);
return JsonSerializer.Deserialize<MusixMatchResponse<TrackSearchResponse>>(jsonResponse, _jsonOptions)
?? throw new JsonException("Failed to deserialize track search response");
}
public async Task<MusixMatchResponse<LyricsResponse>> GetTrackLyricsAsync(int trackId)
{
var endpoint = $"track.lyrics.get?app_id=community-app-v1.0&format=json&track_id={trackId}";
var jsonResponse = await MakeRequestAsync(endpoint);
return JsonSerializer.Deserialize<MusixMatchResponse<LyricsResponse>>(jsonResponse, _jsonOptions)
?? throw new JsonException("Failed to deserialize lyrics response");
}
private async Task<string> MakeRequestAsync(string endpoint)
{
var fullUrl = _baseUrl + endpoint;
var signedUrl = fullUrl + await GenerateSignature(fullUrl);
var request = new HttpRequestMessage(HttpMethod.Get, signedUrl);
request.Headers.UserAgent.ParseAdd(_userAgent);
request.Headers.Add("Cookie", "mxm_bab=AB");
var response = await _httpClient.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
Log.Warning("Error in musix api request. Status: {ResponseStatusCode}, Content: {Content}",
response.StatusCode,
content);
response.EnsureSuccessStatusCode(); // This will throw with the appropriate status code
}
return content;
}
}
}

View file

@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Musix.Models
{
public class MusixMatchResponse<T>
{
[JsonPropertyName("message")]
public Message<T> Message { get; set; } = null!;
}
}

View file

@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class Track
{
[JsonPropertyName("track_id")]
public int TrackId { get; set; }
[JsonPropertyName("track_name")]
public string TrackName { get; set; } = string.Empty;
[JsonPropertyName("artist_name")]
public string ArtistName { get; set; } = string.Empty;
[JsonPropertyName("album_name")]
public string AlbumName { get; set; } = string.Empty;
[JsonPropertyName("track_share_url")]
public string TrackShareUrl { get; set; } = string.Empty;
public override string ToString() => $"{TrackName} by {ArtistName} (Album: {AlbumName})";
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class TrackListItem
{
[JsonPropertyName("track")]
public Track Track { get; set; } = null!;
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class TrackSearchResponse
{
[JsonPropertyName("track_list")]
public List<TrackListItem> TrackList { get; set; } = new();
}

View file

@ -0,0 +1,3 @@
namespace EllieBot.Modules.Music;
public record struct TracksItem(string Author, string Title, int Id);

View file

@ -34,7 +34,7 @@ public class CryptoService : IEService
var gElement = xml["svg"]?["g"];
if (gElement is null)
return Array.Empty<PointF>();
return [];
Span<PointF> points = new PointF[gElement.ChildNodes.Count];
var cnt = 0;
@ -73,7 +73,7 @@ public class CryptoService : IEService
}
if (cnt == 0)
return Array.Empty<PointF>();
return [];
return points.Slice(0, cnt).ToArray();
}

View file

@ -20,7 +20,6 @@ public partial class Utility
# txt: Quote text
""";
private static readonly ISerializer _exportSerializer = new SerializerBuilder()
.WithEventEmitter(args
=> new MultilineScalarFlowStyleEmitter(args))

View file

@ -0,0 +1,21 @@
namespace EllieBot.Modules.Utility.UserRole;
public interface IDiscordRoleManager
{
/// <summary>
/// Modifies a role's properties in Discord
/// </summary>
/// <param name="guildId">The ID of the guild containing the role</param>
/// <param name="roleId">ID of the role to modify</param>
/// <param name="name">New name for the role (optional)</param>
/// <param name="color">New color for the role (optional)</param>
/// <param name="image">Image for the role (optional)</param>
/// <returns>True if successful, false otherwise</returns>
Task<bool> ModifyRoleAsync(
ulong guildId,
ulong roleId,
string? name = null,
Color? color = null,
Image? image = null
);
}

View file

@ -0,0 +1,76 @@
using SixLabors.ImageSharp.PixelFormats;
namespace EllieBot.Modules.Utility.UserRole;
public interface IUserRoleService
{
/// <summary>
/// Assigns a role to a user and updates both database and Discord
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="userId">ID of the user</param>
/// <param name="roleId">ID of the role</param>
/// <returns>True if successful, false otherwise</returns>
Task<bool> AddRoleAsync(ulong guildId, ulong userId, ulong roleId);
/// <summary>
/// Removes a role from a user and updates both database and Discord
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="userId">ID of the user</param>
/// <param name="roleId">ID of the role</param>
/// <returns>True if successful, false otherwise</returns>
Task<bool> RemoveRoleAsync(ulong guildId, ulong userId, ulong roleId);
/// <summary>
/// Gets all user roles for a guild
/// </summary>
/// <param name="guildId">ID of the guild</param>
Task<IReadOnlyCollection<UserRole>> ListRolesAsync(ulong guildId);
/// <summary>
/// Gets all roles for a specific user in a guild
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="userId">ID of the user</param>
Task<IReadOnlyCollection<UserRole>> ListUserRolesAsync(ulong guildId, ulong userId);
/// <summary>
/// Sets the custom color for a user's role and updates both database and Discord
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="userId">ID of the user</param>
/// <param name="roleId">ID of the role</param>
/// <param name="color">Hex color code</param>
/// <returns>True if successful, false otherwise</returns>
Task<bool> SetRoleColorAsync(ulong guildId, ulong userId, ulong roleId, Rgba32 color);
/// <summary>
/// Sets the custom name for a user's role and updates both database and Discord
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="userId">ID of the user</param>
/// <param name="roleId">ID of the role</param>
/// <param name="name">New role name</param>
/// <returns>True if successful, false otherwise</returns>
Task<bool> SetRoleNameAsync(ulong guildId, ulong userId, ulong roleId, string name);
/// <summary>
/// Sets the custom icon for a user's role and updates both database and Discord
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="userId">ID of the user</param>
/// <param name="roleId">ID of the role</param>
/// <param name="icon">Icon URL or emoji</param>
/// <returns>True if successful, false otherwise</returns>
Task<bool> SetRoleIconAsync(ulong guildId, ulong userId, ulong roleId, string icon);
/// <summary>
/// Checks if a user has a specific role assigned
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="userId">ID of the user</param>
/// <param name="roleId">ID of the role</param>
/// <returns>True if the user has the role, false otherwise</returns>
Task<bool> UserOwnsRoleAsync(ulong guildId, ulong userId, ulong roleId);
}

View file

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace EllieBot.Modules.Utility.UserRole;
/// <summary>
/// Represents a user's assigned role in a guild
/// </summary>
public class UserRole
{
/// <summary>
/// ID of the guild
/// </summary>
public ulong GuildId { get; set; }
/// <summary>
/// ID of the user
/// </summary>
public ulong UserId { get; set; }
/// <summary>
/// ID of the Discord role
/// </summary>
public ulong RoleId { get; set; }
}
public class UserRoleConfiguration : IEntityTypeConfiguration<UserRole>
{
public void Configure(EntityTypeBuilder<UserRole> builder)
{
// Set composite primary key
builder.HasKey(x => new { x.GuildId, x.UserId, x.RoleId });
// Create indexes for frequently queried columns
builder.HasIndex(x => x.GuildId);
builder.HasIndex(x => new { x.GuildId, x.UserId });
}
}

View file

@ -0,0 +1,262 @@
using EllieBot.Modules.Utility.UserRole;
using SixLabors.ImageSharp.PixelFormats;
namespace EllieBot.Modules.Utility;
public partial class Utility
{
[Group]
public class UserRoleCommands : EllieModule
{
private readonly IUserRoleService _urs;
public UserRoleCommands(IUserRoleService userRoleService)
{
_urs = userRoleService;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task UserRoleAssign(IGuildUser user, IRole role)
{
var modUser = (IGuildUser)ctx.User;
if (modUser.GetRoles().Max(x => x.Position) <= role.Position)
{
await Response().Error(strs.userrole_hierarchy_error).SendAsync();
return;
}
var botUser = ((SocketGuild)ctx.Guild).CurrentUser;
if (botUser.GetRoles().Max(x => x.Position) <= role.Position)
{
await Response().Error(strs.hierarchy).SendAsync();
return;
}
var success = await _urs.AddRoleAsync(ctx.Guild.Id, user.Id, role.Id);
if (!success)
return;
await Response()
.Confirm(strs.userrole_assigned(Format.Bold(user.ToString()), Format.Bold(role.Name)))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task UserRoleRemove(IUser user, IRole role)
{
var modUser = (IGuildUser)ctx.User;
if (modUser.GetRoles().Max(x => x.Position) <= role.Position)
{
await Response().Error(strs.userrole_hierarchy_error).SendAsync();
return;
}
var success = await _urs.RemoveRoleAsync(ctx.Guild.Id, user.Id, role.Id);
if (!success)
{
await Response().Error(strs.userrole_not_found).SendAsync();
return;
}
await Response()
.Confirm(strs.userrole_removed(Format.Bold(user.ToString()), Format.Bold(role.Name)))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task UserRoleList()
{
var roles = await _urs.ListRolesAsync(ctx.Guild.Id);
if (roles.Count == 0)
{
await Response().Error(strs.userrole_none).SendAsync();
return;
}
var guild = ctx.Guild as SocketGuild;
// Group roles by user
var userGroups = roles.GroupBy(r => r.UserId)
.Select(g => (UserId: g.Key,
UserName: guild?.GetUser(g.Key)?.ToString() ?? g.Key.ToString(),
Roles: g.ToList()))
.ToList();
await Response()
.Paginated()
.Items(userGroups)
.PageSize(5)
.Page((pageUsers, _) =>
{
var eb = CreateEmbed()
.WithTitle(GetText(strs.userrole_list_title))
.WithOkColor();
foreach (var user in pageUsers)
{
var roleNames = user.Roles
.Select(r => $"- {guild?.GetRole(r.RoleId)} `{r.RoleId}`")
.Join("\n");
eb.AddField(user.UserName, roleNames);
}
return eb;
})
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task UserRoleList(IUser user)
{
var roles = await _urs.ListUserRolesAsync(ctx.Guild.Id, user.Id);
if (roles.Count == 0)
{
await Response()
.Error(strs.userrole_none_user(Format.Bold(user.ToString())))
.SendAsync();
return;
}
var guild = ctx.Guild as SocketGuild;
await Response()
.Paginated()
.Items(roles)
.PageSize(10)
.Page((pageRoles, _) =>
{
var roleList = pageRoles
.Select(r => $"- {guild?.GetRole(r.RoleId)} `{r.RoleId}`")
.Join("\n");
return CreateEmbed()
.WithTitle(GetText(strs.userrole_list_for_user(Format.Bold(user.ToString()))))
.WithDescription(roleList)
.WithOkColor();
})
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task UserRoleMy()
=> await UserRoleList(ctx.User);
[Cmd]
[RequireContext(ContextType.Guild)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task UserRoleColor(IRole role, Rgba32 color)
{
if (!await _urs.UserOwnsRoleAsync(ctx.Guild.Id, ctx.User.Id, role.Id))
{
await Response().Error(strs.userrole_no_permission).SendAsync();
return;
}
var success = await _urs.SetRoleColorAsync(
ctx.Guild.Id,
ctx.User.Id,
role.Id,
color
);
if (success)
{
await Response().Confirm(strs.userrole_color_success(Format.Bold(role.Name), color)).SendAsync();
}
else
{
await Response().Error(strs.userrole_color_fail).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task UserRoleName(IRole role, [Leftover] string name)
{
if (string.IsNullOrWhiteSpace(name) || name.Length > 100)
{
await Response().Error(strs.userrole_name_invalid).SendAsync();
return;
}
if (!await _urs.UserOwnsRoleAsync(ctx.Guild.Id, ctx.User.Id, role.Id))
{
await Response().Error(strs.userrole_no_permission).SendAsync();
return;
}
var success = await _urs.SetRoleNameAsync(
ctx.Guild.Id,
ctx.User.Id,
role.Id,
name
);
if (success)
{
await Response().Confirm(strs.userrole_name_success(Format.Bold(name))).SendAsync();
}
else
{
await Response().Error(strs.userrole_name_fail).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[BotPerm(GuildPerm.ManageRoles)]
public Task UserRoleIcon(IRole role, Emote emote)
=> UserRoleIcon(role, emote.Url);
[Cmd]
[RequireContext(ContextType.Guild)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task UserRoleIcon(IRole role, [Leftover] string icon)
{
if (string.IsNullOrWhiteSpace(icon))
{
await Response().Error(strs.userrole_icon_invalid).SendAsync();
return;
}
if (!await _urs.UserOwnsRoleAsync(ctx.Guild.Id, ctx.User.Id, role.Id))
{
await Response().Error(strs.userrole_no_permission).SendAsync();
return;
}
var success = await _urs.SetRoleIconAsync(
ctx.Guild.Id,
ctx.User.Id,
role.Id,
icon
);
if (success)
{
await Response().Confirm(strs.userrole_icon_success(Format.Bold(role.Name))).SendAsync();
}
else
{
await Response().Error(strs.userrole_icon_fail).SendAsync();
}
}
}
}

View file

@ -0,0 +1,52 @@
namespace EllieBot.Modules.Utility.UserRole;
public class UserRoleDiscordManager(DiscordSocketClient client) : IDiscordRoleManager, IEService
{
/// <summary>
/// Modifies a role's properties in Discord
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="roleId">ID of the role to modify</param>
/// <param name="name">New name for the role (optional)</param>
/// <param name="color">New color for the role (optional)</param>
/// <param name="image">New emoji for the role (optional)</param>
/// <returns>True if successful, false otherwise</returns>
public async Task<bool> ModifyRoleAsync(
ulong guildId,
ulong roleId,
string? name = null,
Color? color = null,
Image? image = null
)
{
try
{
var guild = client.GetGuild(guildId);
if (guild is null)
return false;
var role = guild.GetRole(roleId);
if (role is null)
return false;
await role.ModifyAsync(properties =>
{
if (name is not null)
properties.Name = name;
if (color is not null)
properties.Color = color.Value;
if (image is not null)
properties.Icon = image;
});
return true;
}
catch (Exception ex)
{
Log.Warning(ex, "Unable to modify role {RoleId}", roleId);
return false;
}
}
}

View file

@ -0,0 +1,184 @@
#nullable disable warnings
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp.PixelFormats;
namespace EllieBot.Modules.Utility.UserRole;
public sealed class UserRoleService : IUserRoleService, IEService
{
private readonly DbService _db;
private readonly IDiscordRoleManager _discordRoleManager;
private readonly IHttpClientFactory _httpClientFactory;
public UserRoleService(
DbService db,
IDiscordRoleManager discordRoleManager,
IHttpClientFactory httpClientFactory)
{
_db = db;
_discordRoleManager = discordRoleManager;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Assigns a role to a user and updates both database and Discord
/// </summary>
public async Task<bool> AddRoleAsync(ulong guildId, ulong userId, ulong roleId)
{
await using var ctx = _db.GetDbContext();
await ctx.GetTable<UserRole>()
.InsertOrUpdateAsync(() => new UserRole
{
GuildId = guildId,
UserId = userId,
RoleId = roleId,
},
_ => new() { },
() => new()
{
GuildId = guildId,
UserId = userId,
RoleId = roleId
});
return true;
}
/// <summary>
/// Removes a role from a user and updates both database and Discord
/// </summary>
public async Task<bool> RemoveRoleAsync(ulong guildId, ulong userId, ulong roleId)
{
await using var ctx = _db.GetDbContext();
var deleted = await ctx.GetTable<UserRole>()
.Where(r => r.GuildId == guildId && r.UserId == userId && r.RoleId == roleId)
.DeleteAsync();
return deleted > 0;
}
/// <summary>
/// Gets all user roles for a guild
/// </summary>
public async Task<IReadOnlyCollection<UserRole>> ListRolesAsync(ulong guildId)
{
await using var ctx = _db.GetDbContext();
var roles = await ctx.GetTable<UserRole>()
.AsNoTracking()
.Where(r => r.GuildId == guildId)
.ToListAsyncLinqToDB();
return roles;
}
/// <summary>
/// Gets all roles for a specific user in a guild
/// </summary>
public async Task<IReadOnlyCollection<UserRole>> ListUserRolesAsync(ulong guildId, ulong userId)
{
await using var ctx = _db.GetDbContext();
var roles = await ctx.GetTable<UserRole>()
.AsNoTracking()
.Where(r => r.GuildId == guildId && r.UserId == userId)
.ToListAsyncLinqToDB();
return roles;
}
/// <summary>
/// Sets the custom color for a user's role and updates both database and Discord
/// </summary>
public async Task<bool> SetRoleColorAsync(ulong guildId, ulong userId, ulong roleId, Rgba32 color)
{
var discordSuccess = await _discordRoleManager.ModifyRoleAsync(
guildId,
roleId,
color: color.ToDiscordColor());
return discordSuccess;
}
/// <summary>
/// Sets the custom name for a user's role and updates both database and Discord
/// </summary>
public async Task<bool> SetRoleNameAsync(ulong guildId, ulong userId, ulong roleId, string name)
{
var discordSuccess = await _discordRoleManager.ModifyRoleAsync(
guildId,
roleId,
name: name);
return discordSuccess;
}
/// <summary>
/// Sets the custom icon for a user's role and updates both database and Discord
/// </summary>
public async Task<bool> SetRoleIconAsync(ulong guildId, ulong userId, ulong roleId, string iconUrl)
{
// Validate the URL format
if (!Uri.TryCreate(iconUrl, UriKind.Absolute, out var uri))
return false;
try
{
// Download the image
using var httpClient = _httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
// Check if the response is successful
if (!response.IsSuccessStatusCode)
return false;
// Check content type - must be image/png or image/jpeg
var contentType = response.Content.Headers.ContentType?.MediaType?.ToLower();
if (contentType != "image/png"
&& contentType != "image/jpeg"
&& contentType != "image/webp")
return false;
// Check file size - Discord limit is 256KB
var contentLength = response.Content.Headers.ContentLength;
if (contentLength is > 256 * 1024)
return false;
// Save the image to a memory stream
await using var stream = await response.Content.ReadAsStreamAsync();
await using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
// Create Discord image from stream
var discordImage = new Image(memoryStream);
// Upload the image to Discord
var discordSuccess = await _discordRoleManager.ModifyRoleAsync(
guildId,
roleId,
image: discordImage);
return discordSuccess;
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to process role icon from URL {IconUrl}", iconUrl);
return false;
}
}
/// <summary>
/// Checks if a user has a specific role assigned
/// </summary>
public async Task<bool> UserOwnsRoleAsync(ulong guildId, ulong userId, ulong roleId)
{
await using var ctx = _db.GetDbContext();
return await ctx.GetTable<UserRole>()
.AnyAsyncLinqToDB(r => r.GuildId == guildId && r.UserId == userId && r.RoleId == roleId);
}
}

View file

@ -85,10 +85,10 @@ public partial class Utility : EllieModule
message = await repSvc.ReplaceAsync(message, repCtx);
await Response()
.Text(message)
.Channel(channel)
.UserBasedMentions()
.SendAsync();
.Text(message)
.Channel(channel)
.UserBasedMentions()
.SendAsync();
}
[Cmd]
@ -123,27 +123,27 @@ public partial class Utility : EllieModule
}
await Response()
.Sanitize()
.Paginated()
.Items(userNames)
.PageSize(20)
.Page((names, _) =>
{
if (names.Count == 0)
{
return CreateEmbed()
.WithErrorColor()
.WithDescription(GetText(strs.nobody_playing_game));
}
.Sanitize()
.Paginated()
.Items(userNames)
.PageSize(20)
.Page((names, _) =>
{
if (names.Count == 0)
{
return CreateEmbed()
.WithErrorColor()
.WithDescription(GetText(strs.nobody_playing_game));
}
var eb = CreateEmbed()
.WithOkColor();
var eb = CreateEmbed()
.WithOkColor();
var users = names.Join('\n');
var users = names.Join('\n');
return eb.WithDescription(users);
})
.SendAsync();
return eb.WithDescription(users);
})
.SendAsync();
}
[Cmd]
@ -161,9 +161,11 @@ public partial class Utility : EllieModule
CacheMode.CacheOnly
);
users = role is null
? users
: users.Where(u => u.RoleIds.Contains(role.Id)).ToList();
users = (role is null
? users
: users.Where(u => u.RoleIds.Contains(role.Id)))
.OrderBy(x => x.DisplayName)
.ToList();
var roleUsers = new List<string>(users.Count);
@ -173,23 +175,23 @@ public partial class Utility : EllieModule
}
await Response()
.Paginated()
.Items(roleUsers)
.PageSize(20)
.CurrentPage(page)
.Page((pageUsers, _) =>
{
if (pageUsers.Count == 0)
return CreateEmbed().WithOkColor().WithDescription(GetText(strs.no_user_on_this_page));
.Paginated()
.Items(roleUsers)
.PageSize(20)
.CurrentPage(page)
.Page((pageUsers, _) =>
{
if (pageUsers.Count == 0)
return CreateEmbed().WithOkColor().WithDescription(GetText(strs.no_user_on_this_page));
var roleName = Format.Bold(role?.Name ?? "No Role");
var roleName = Format.Bold(role?.Name ?? "No Role");
return CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.inrole_list(role?.GetIconUrl() + roleName, roleUsers.Count)))
.WithDescription(string.Join("\n", pageUsers));
})
.SendAsync();
return CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.inrole_list(role?.GetIconUrl() + roleName, roleUsers.Count)))
.WithDescription(string.Join("\n", pageUsers));
})
.SendAsync();
}
[Cmd]
@ -211,14 +213,14 @@ public partial class Utility : EllieModule
{
var builder = new StringBuilder();
foreach (var p in perms.GetType()
.GetProperties()
.Where(static p =>
{
var method = p.GetGetMethod();
if (method is null)
return false;
return !method.GetParameters().Any();
}))
.GetProperties()
.Where(static p =>
{
var method = p.GetGetMethod();
if (method is null)
return false;
return !method.GetParameters().Any();
}))
builder.AppendLine($"{p.Name} : {p.GetValue(perms, null)}");
await Response().Confirm(builder.ToString()).SendAsync();
}
@ -229,20 +231,20 @@ public partial class Utility : EllieModule
{
var usr = target ?? ctx.User;
await Response()
.Confirm(strs.userid("🆔",
Format.Bold(usr.ToString()),
Format.Code(usr.Id.ToString())))
.SendAsync();
.Confirm(strs.userid("🆔",
Format.Bold(usr.ToString()),
Format.Code(usr.Id.ToString())))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task RoleId([Leftover] IRole role)
=> await Response()
.Confirm(strs.roleid("🆔",
Format.Bold(role.ToString()),
Format.Code(role.Id.ToString())))
.SendAsync();
.Confirm(strs.roleid("🆔",
Format.Bold(role.ToString()),
Format.Code(role.Id.ToString())))
.SendAsync();
[Cmd]
public async Task ChannelId()
@ -267,36 +269,36 @@ public partial class Utility : EllieModule
if (target is not null)
{
var roles = target.GetRoles()
.Except(new[] { guild.EveryoneRole })
.OrderBy(r => -r.Position)
.Skip((page - 1) * rolesPerPage)
.Take(rolesPerPage)
.ToArray();
.Except(new[] { guild.EveryoneRole })
.OrderBy(r => -r.Position)
.Skip((page - 1) * rolesPerPage)
.Take(rolesPerPage)
.ToArray();
if (!roles.Any())
await Response().Error(strs.no_roles_on_page).SendAsync();
else
{
await Response()
.Confirm(GetText(strs.roles_page(page, Format.Bold(target.ToString()))),
"\n• " + string.Join("\n• ", (IEnumerable<IRole>)roles))
.SendAsync();
.Confirm(GetText(strs.roles_page(page, Format.Bold(target.ToString()))),
"\n• " + string.Join("\n• ", (IEnumerable<IRole>)roles))
.SendAsync();
}
}
else
{
var roles = guild.Roles.Except(new[] { guild.EveryoneRole })
.OrderBy(r => -r.Position)
.Skip((page - 1) * rolesPerPage)
.Take(rolesPerPage)
.ToArray();
.OrderBy(r => -r.Position)
.Skip((page - 1) * rolesPerPage)
.Take(rolesPerPage)
.ToArray();
if (!roles.Any())
await Response().Error(strs.no_roles_on_page).SendAsync();
else
{
await Response()
.Confirm(GetText(strs.roles_all_page(page)),
"\n• " + string.Join("\n• ", (IEnumerable<IRole>)roles).SanitizeMentions(true))
.SendAsync();
.Confirm(GetText(strs.roles_all_page(page)),
"\n• " + string.Join("\n• ", (IEnumerable<IRole>)roles).SanitizeMentions(true))
.SendAsync();
}
}
}
@ -328,33 +330,33 @@ public partial class Utility : EllieModule
ownerIds = "-";
var eb = CreateEmbed()
.WithOkColor()
.WithAuthor($"EllieBot v{StatsService.BotVersion}",
"https://cdn.elliebot.net/Ellie.png",
"https://docs.elliebot.net")
.AddField(GetText(strs.author), _stats.Author, true)
.AddField(GetText(strs.botid), _client.CurrentUser.Id.ToString(), true)
.AddField(GetText(strs.shard),
$"#{_client.ShardId} / {_creds.TotalShards}",
true)
.AddField(GetText(strs.commands_ran), _stats.CommandsRan.ToString(), true)
.AddField(GetText(strs.messages),
$"{_stats.MessageCounter} ({_stats.MessagesPerSecond:F2}/sec)",
true)
.AddField(GetText(strs.memory),
FormattableString.Invariant($"{_stats.GetPrivateMemoryMegabytes():F2} MB"),
true)
.AddField(GetText(strs.owner_ids), ownerIds, true)
.AddField(GetText(strs.uptime), _stats.GetUptimeString("\n"), true)
.AddField(GetText(strs.presence),
GetText(strs.presence_txt(_coord.GetGuildCount(),
_stats.TextChannels,
_stats.VoiceChannels)),
true);
.WithOkColor()
.WithAuthor($"EllieBot v{StatsService.BotVersion}",
"https://cdn.elliebot.net/Ellie.png",
"https://docs.elliebot.net")
.AddField(GetText(strs.author), _stats.Author, true)
.AddField(GetText(strs.botid), _client.CurrentUser.Id.ToString(), true)
.AddField(GetText(strs.shard),
$"#{_client.ShardId} / {_creds.TotalShards}",
true)
.AddField(GetText(strs.commands_ran), _stats.CommandsRan.ToString(), true)
.AddField(GetText(strs.messages),
$"{_stats.MessageCounter} ({_stats.MessagesPerSecond:F2}/sec)",
true)
.AddField(GetText(strs.memory),
FormattableString.Invariant($"{_stats.GetPrivateMemoryMegabytes():F2} MB"),
true)
.AddField(GetText(strs.owner_ids), ownerIds, true)
.AddField(GetText(strs.uptime), _stats.GetUptimeString("\n"), true)
.AddField(GetText(strs.presence),
GetText(strs.presence_txt(_coord.GetGuildCount(),
_stats.TextChannels,
_stats.VoiceChannels)),
true);
await Response()
.Embed(eb)
.SendAsync();
.Embed(eb)
.SendAsync();
}
[Cmd]
@ -503,9 +505,9 @@ public partial class Utility : EllieModule
}
format = attach.Filename
.Split('.')
.Last()
.ToLowerInvariant();
.Split('.')
.Last()
.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(format) || (format != "png" && format != "apng"))
{
@ -572,30 +574,30 @@ public partial class Utility : EllieModule
return;
var allGuilds = _client.Guilds
.OrderBy(g => g.Name)
.ToList();
.OrderBy(g => g.Name)
.ToList();
await Response()
.Paginated()
.Items(allGuilds)
.PageSize(9)
.Page((guilds, _) =>
{
if (!guilds.Any())
{
return CreateEmbed()
.WithDescription(GetText(strs.listservers_none))
.WithErrorColor();
}
.Paginated()
.Items(allGuilds)
.PageSize(9)
.Page((guilds, _) =>
{
if (!guilds.Any())
{
return CreateEmbed()
.WithDescription(GetText(strs.listservers_none))
.WithErrorColor();
}
var embed = CreateEmbed()
.WithOkColor();
foreach (var guild in guilds)
embed.AddField(guild.Name, GetText(strs.listservers(guild.Id, guild.MemberCount, guild.OwnerId)));
var embed = CreateEmbed()
.WithOkColor();
foreach (var guild in guilds)
embed.AddField(guild.Name, GetText(strs.listservers(guild.Id, guild.MemberCount, guild.OwnerId)));
return embed;
})
.SendAsync();
return embed;
})
.SendAsync();
}
[Cmd]
@ -632,7 +634,7 @@ public partial class Utility : EllieModule
{
Content = msg.Content,
Embeds = msg.Embeds
.Map(x => new SmartEmbedArrayElementText(x))
.Map(x => new SmartEmbedArrayElementText(x))
}.ToJson(_showEmbedSerializerOptions);
await Response().Confirm(Format.Code(json, "json").Replace("](", "]\\(")).SendAsync();
@ -648,34 +650,34 @@ public partial class Utility : EllieModule
var title = $"Chatlog-{ctx.Guild.Name}/#{ctx.Channel.Name}-{DateTime.Now}.txt";
var grouping = msgs.GroupBy(x => $"{x.CreatedAt.Date:dd.MM.yyyy}")
.Select(g => new
{
date = g.Key,
messages = g.OrderBy(x => x.CreatedAt)
.Select(s =>
{
var msg = $"【{s.Timestamp:HH:mm:ss}】{s.Author}:";
if (string.IsNullOrWhiteSpace(s.ToString()))
{
if (s.Attachments.Any())
{
msg += "FILES_UPLOADED: "
+ string.Join("\n", s.Attachments.Select(x => x.Url));
}
else if (s.Embeds.Any())
{
msg += "EMBEDS: "
+ string.Join("\n--------\n",
s.Embeds.Select(x
=> $"Description: {x.Description}"));
}
}
else
msg += s.ToString();
.Select(g => new
{
date = g.Key,
messages = g.OrderBy(x => x.CreatedAt)
.Select(s =>
{
var msg = $"【{s.Timestamp:HH:mm:ss}】{s.Author}:";
if (string.IsNullOrWhiteSpace(s.ToString()))
{
if (s.Attachments.Any())
{
msg += "FILES_UPLOADED: "
+ string.Join("\n", s.Attachments.Select(x => x.Url));
}
else if (s.Embeds.Any())
{
msg += "EMBEDS: "
+ string.Join("\n--------\n",
s.Embeds.Select(x
=> $"Description: {x.Description}"));
}
}
else
msg += s.ToString();
return msg;
})
});
return msg;
})
});
await using var stream = await JsonConvert.SerializeObject(grouping, Formatting.Indented).ToStream();
await ctx.User.SendFileAsync(stream, title, title);
}
@ -690,8 +692,8 @@ public partial class Utility : EllieModule
msg.DeleteAfter(0);
await Response()
.Confirm($"{Format.Bold(ctx.User.ToString())} 🏓 {(int)sw.Elapsed.TotalMilliseconds}ms")
.SendAsync();
.Confirm($"{Format.Bold(ctx.User.ToString())} 🏓 {(int)sw.Elapsed.TotalMilliseconds}ms")
.SendAsync();
}
[Cmd]
@ -715,8 +717,8 @@ public partial class Utility : EllieModule
if (succ)
{
await Response()
.Confirm(strs.afk_set)
.SendAsync();
.Confirm(strs.afk_set)
.SendAsync();
}
}
@ -737,22 +739,22 @@ public partial class Utility : EllieModule
var script = CSharpScript.Create(scriptText,
ScriptOptions.Default
.WithReferences(this.GetType().Assembly)
.WithImports(
"System",
"System.Collections.Generic",
"System.IO",
"System.Linq",
"System.Net.Http",
"System.Threading",
"System.Threading.Tasks",
"EllieBot",
"EllieBot.Extensions",
"Microsoft.Extensions.DependencyInjection",
"EllieBot.Common",
"EllieBot.Modules",
"System.Text",
"System.Text.Json"),
.WithReferences(this.GetType().Assembly)
.WithImports(
"System",
"System.Collections.Generic",
"System.IO",
"System.Linq",
"System.Net.Http",
"System.Threading",
"System.Threading.Tasks",
"EllieBot",
"EllieBot.Extensions",
"Microsoft.Extensions.DependencyInjection",
"EllieBot.Common",
"EllieBot.Modules",
"System.Text",
"System.Text.Json"),
globalsType: typeof(EvalGlobals));
try
@ -771,9 +773,9 @@ public partial class Utility : EllieModule
if (!string.IsNullOrWhiteSpace(output))
{
var eb = CreateEmbed()
.WithOkColor()
.AddField("Code", scriptText)
.AddField("Output", output.TrimTo(512)!);
.WithOkColor()
.AddField("Code", scriptText)
.AddField("Output", output.TrimTo(512)!);
_ = Response().Embed(eb).SendAsync();
}
@ -790,19 +792,21 @@ public partial class Utility : EllieModule
if (ctx.Message.ReferencedMessage is not { } msg)
{
var msgs = await ctx.Channel.GetMessagesAsync(ctx.Message, Direction.Before, 3).FlattenAsync();
msg = msgs.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Content) || (x.Attachments.FirstOrDefault()?.Width is not null)) as IUserMessage;
msg = msgs.FirstOrDefault(x
=> !string.IsNullOrWhiteSpace(x.Content) ||
(x.Attachments.FirstOrDefault()?.Width is not null)) as IUserMessage;
if (msg is null)
return;
}
var eb = CreateEmbed()
.WithOkColor()
.WithDescription(msg.Content)
.WithAuthor(msg.Author)
.WithTimestamp(msg.Timestamp)
.WithImageUrl(msg.Attachments.FirstOrDefault()?.Url)
.WithFooter(GetText(strs.sniped_by(ctx.User.ToString())), ctx.User.GetDisplayAvatarUrl());
.WithOkColor()
.WithDescription(msg.Content)
.WithAuthor(msg.Author)
.WithTimestamp(msg.Timestamp)
.WithImageUrl(msg.Attachments.FirstOrDefault()?.Url)
.WithFooter(GetText(strs.sniped_by(ctx.User.ToString())), ctx.User.GetDisplayAvatarUrl());
ctx.Message.DeleteAfter(1);
await Response().Embed(eb).SendAsync();

View file

@ -29,13 +29,13 @@ public partial class Xp
else
{
await Response()
.Confirm(
strs.club_transfered(
Format.Bold(club.Name),
Format.Bold(newOwner.ToString())
)
)
.SendAsync();
.Confirm(
strs.club_transfered(
Format.Bold(club.Name),
Format.Bold(newOwner.ToString())
)
)
.SendAsync();
}
}
@ -108,57 +108,85 @@ public partial class Xp
await Response().Error(strs.club_icon_invalid_filetype).SendAsync();
}
[Cmd]
public async Task ClubBanner([Leftover] string url = null)
{
if ((!Uri.IsWellFormedUriString(url, UriKind.Absolute) && url is not null))
{
await Response().Error(strs.club_icon_url_format).SendAsync();
return;
}
var result = await _service.SetClubBannerAsync(ctx.User.Id, url);
if (result == SetClubIconResult.Success)
{
if (url is null)
await Response().Confirm(strs.club_banner_reset).SendAsync();
else
await Response().Confirm(strs.club_banner_set).SendAsync();
}
else if (result == SetClubIconResult.NotOwner)
await Response().Error(strs.club_owner_only).SendAsync();
else if (result == SetClubIconResult.TooLarge)
await Response().Error(strs.club_icon_too_large).SendAsync();
else if (result == SetClubIconResult.InvalidFileType)
await Response().Error(strs.club_icon_invalid_filetype).SendAsync();
}
private async Task InternalClubInfoAsync(ClubInfo club)
{
var lvl = new LevelStats(club.Xp);
var allUsers = club.Members.OrderByDescending(x =>
{
var l = new LevelStats(x.TotalXp).Level;
if (club.OwnerId == x.Id)
return int.MaxValue;
if (x.IsClubAdmin)
return (int.MaxValue / 2) + l;
return l;
})
.ToList();
{
var l = new LevelStats(x.TotalXp).Level;
if (club.OwnerId == x.Id)
return int.MaxValue;
if (x.IsClubAdmin)
return (int.MaxValue / 2) + l;
return l;
})
.ToList();
var rank = await _service.GetClubRankAsync(club.Id);
await Response()
.Paginated()
.Items(allUsers)
.PageSize(10)
.Page((users, _) =>
{
var embed = CreateEmbed()
.WithOkColor()
.WithTitle($"{club}")
.WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)")))
.AddField(GetText(strs.desc),
string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description)
.AddField(GetText(strs.rank), $"#{rank}", true)
.AddField(GetText(strs.owner), club.Owner.ToString(), true)
// .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true)
.AddField(GetText(strs.members),
string.Join("\n",
users
.Select(x =>
{
var l = new LevelStats(x.TotalXp);
var lvlStr = Format.Bold($" ⟪{l.Level}⟫");
if (club.OwnerId == x.Id)
return x + "🌟" + lvlStr;
if (x.IsClubAdmin)
return x + "⭐" + lvlStr;
return x + lvlStr;
})));
.Paginated()
.Items(allUsers)
.PageSize(10)
.Page((users, _) =>
{
var embed = CreateEmbed()
.WithOkColor()
.WithTitle($"{club}")
.WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)")))
.AddField(GetText(strs.desc),
string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description)
.AddField(GetText(strs.rank), $"#{rank}", true)
.AddField(GetText(strs.owner), club.Owner.ToString(), true)
// .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true)
.AddField(GetText(strs.members),
string.Join("\n",
users
.Select(x =>
{
var l = new LevelStats(x.TotalXp);
var lvlStr = Format.Bold($" ⟪{l.Level}⟫");
if (club.OwnerId == x.Id)
return x + "🌟" + lvlStr;
if (x.IsClubAdmin)
return x + "⭐" + lvlStr;
return x + lvlStr;
})));
if (Uri.IsWellFormedUriString(club.ImageUrl, UriKind.Absolute))
return embed.WithThumbnailUrl(club.ImageUrl);
if (Uri.IsWellFormedUriString(club.ImageUrl, UriKind.Absolute))
embed.WithThumbnailUrl(club.ImageUrl);
return embed;
})
.SendAsync();
if (Uri.IsWellFormedUriString(club.BannerUrl, UriKind.Absolute))
embed.WithImageUrl(club.BannerUrl);
return embed;
})
.SendAsync();
}
[Cmd]
@ -208,20 +236,20 @@ public partial class Xp
var bans = club.Bans.Select(x => x.User).ToArray();
return Response()
.Paginated()
.Items(bans)
.PageSize(10)
.CurrentPage(page)
.Page((items, _) =>
{
var toShow = string.Join("\n", items.Select(x => x.ToString()));
.Paginated()
.Items(bans)
.PageSize(10)
.CurrentPage(page)
.Page((items, _) =>
{
var toShow = string.Join("\n", items.Select(x => x.ToString()));
return CreateEmbed()
.WithTitle(GetText(strs.club_bans_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
})
.SendAsync();
return CreateEmbed()
.WithTitle(GetText(strs.club_bans_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
})
.SendAsync();
}
[Cmd]
@ -237,20 +265,20 @@ public partial class Xp
var apps = club.Applicants.Select(x => x.User).ToArray();
return Response()
.Paginated()
.Items(apps)
.PageSize(10)
.CurrentPage(page)
.Page((items, _) =>
{
var toShow = string.Join("\n", items.Select(x => x.ToString()));
.Paginated()
.Items(apps)
.PageSize(10)
.CurrentPage(page)
.Page((items, _) =>
{
var toShow = string.Join("\n", items.Select(x => x.ToString()));
return CreateEmbed()
.WithTitle(GetText(strs.club_apps_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
})
.SendAsync();
return CreateEmbed()
.WithTitle(GetText(strs.club_apps_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
})
.SendAsync();
}
[Cmd]
@ -338,9 +366,9 @@ public partial class Xp
if (result == ClubKickResult.Success)
{
return Response()
.Confirm(strs.club_user_kick(Format.Bold(userName),
Format.Bold(club.ToString())))
.SendAsync();
.Confirm(strs.club_user_kick(Format.Bold(userName),
Format.Bold(club.ToString())))
.SendAsync();
}
if (result == ClubKickResult.Hierarchy)
@ -365,9 +393,9 @@ public partial class Xp
if (result == ClubBanResult.Success)
{
return Response()
.Confirm(strs.club_user_banned(Format.Bold(userName),
Format.Bold(club.ToString())))
.SendAsync();
.Confirm(strs.club_user_banned(Format.Bold(userName),
Format.Bold(club.ToString())))
.SendAsync();
}
if (result == ClubBanResult.Unbannable)
@ -393,9 +421,9 @@ public partial class Xp
if (result == ClubUnbanResult.Success)
{
return Response()
.Confirm(strs.club_user_unbanned(Format.Bold(userName),
Format.Bold(club.ToString())))
.SendAsync();
.Confirm(strs.club_user_unbanned(Format.Bold(userName),
Format.Bold(club.ToString())))
.SendAsync();
}
if (result == ClubUnbanResult.WrongUser)
@ -415,11 +443,11 @@ public partial class Xp
? "-"
: desc;
var eb = _sender.CreateEmbed()
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.club_desc_update))
.WithOkColor()
.WithDescription(desc);
var eb = CreateEmbed()
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.club_desc_update))
.WithOkColor()
.WithDescription(desc);
await Response().Embed(eb).SendAsync();
}

View file

@ -120,6 +120,26 @@ public class ClubService : IEService, IClubService
return SetClubIconResult.Success;
}
/// <summary>
/// Sets club banner url
/// </summary>
/// <param name="ownerUserId">User ID of the club owner</param>
/// <param name="url">Banner URL to set</param>
/// <returns>Result of the operation</returns>
public async Task<SetClubIconResult> SetClubBannerAsync(ulong ownerUserId, string? url)
{
await using var uow = _db.GetDbContext();
var club = uow.Set<ClubInfo>().GetByOwner(ownerUserId);
if (club is null)
return SetClubIconResult.NotOwner;
club.BannerUrl = url;
await uow.SaveChangesAsync();
return SetClubIconResult.Success;
}
public bool GetClubByName(string clubName, out ClubInfo club)
{
using var uow = _db.GetDbContext();

View file

@ -10,6 +10,7 @@ public interface IClubService
Task<ToggleAdminResult> ToggleAdminAsync(IUser owner, IUser toAdmin);
ClubInfo? GetClubByMember(IUser user);
Task<SetClubIconResult> SetClubIconAsync(ulong ownerUserId, string? url);
Task<SetClubIconResult> SetClubBannerAsync(ulong ownerUserId, string? url);
bool GetClubByName(string clubName, out ClubInfo club);
ClubApplyResult ApplyToClub(IUser user, ClubInfo club);
ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser? discordUser);

View file

@ -6,7 +6,7 @@ namespace EllieBot.Common;
public sealed class Creds : IBotCreds
{
[Comment("""DO NOT CHANGE""")]
public int Version { get; set; } = 13;
public int Version { get; set; } = 20;
[Comment("""Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/""")]
public string Token { get; set; }
@ -148,8 +148,8 @@ public sealed class Creds : IBotCreds
{0} -> shard id
{1} -> total shards
Linux default
cmd: dotnet
args: "EllieBot.dll -- {0}"
cmd: EllieBot
args: "{0}"
Windows default
cmd: EllieBot.exe
args: "{0}"

View file

@ -77,7 +77,8 @@ public sealed class BotCredsProvider : IBotCredsProvider
if (string.IsNullOrWhiteSpace(_creds.Token))
{
Log.Error("Token is missing from data/creds.yml or Environment variables.\nAdd it and restart the program");
Log.Error(
"Token is missing from data/creds.yml or Environment variables.\nAdd it and restart the program");
Helpers.ReadErrorAndExit(1);
return;
}
@ -85,17 +86,18 @@ public sealed class BotCredsProvider : IBotCredsProvider
if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd)
|| string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args))
{
if (Environment.OSVersion.Platform == PlatformID.Unix)
if (Environment.OSVersion.Platform == PlatformID.Unix ||
Environment.OSVersion.Platform == PlatformID.MacOSX)
{
_creds.RestartCommand = new RestartConfig()
_creds.RestartCommand = new()
{
Args = "dotnet",
Cmd = "EllieBot.dll -- {0}"
Args = "EllieBot",
Cmd = "{0}"
};
}
else
{
_creds.RestartCommand = new RestartConfig()
_creds.RestartCommand = new()
{
Args = "EllieBot.exe",
Cmd = "{0}"

View file

@ -5062,6 +5062,21 @@
"No Public Bot"
]
},
{
"Aliases": [
".lyrics"
],
"Description": "Looks up lyrics for a song. Very hit or miss.",
"Usage": [
".lyrics biri biri"
],
"Submodule": "Music",
"Module": "Music",
"Options": null,
"Requirements": [
"No Public Bot"
]
},
{
"Aliases": [
".playlists",
@ -7987,6 +8002,114 @@
"Module": "Utility",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".userroleassign",
".ura",
".uradd"
],
"Description": "Assigns a role to a user that can later be modified by that user.",
"Usage": [
".userroleassign @User @Role"
],
"Submodule": "UserRoleCommands",
"Module": "Utility",
"Options": null,
"Requirements": [
"ManageRoles Server Permission"
]
},
{
"Aliases": [
".userroleremove",
".urr",
".urdel",
".urrm"
],
"Description": "Removes a previously assigned role from a user.",
"Usage": [
".userroleremove @User @Role"
],
"Submodule": "UserRoleCommands",
"Module": "Utility",
"Options": null,
"Requirements": [
"ManageRoles Server Permission"
]
},
{
"Aliases": [
".userrolelist",
".url"
],
"Description": "Lists all user roles in the server, or for a specific user.",
"Usage": [
".userrolelist",
".userrolelist @User"
],
"Submodule": "UserRoleCommands",
"Module": "Utility",
"Options": null,
"Requirements": [
"ManageRoles Server Permission"
]
},
{
"Aliases": [
".userrolemy",
".urm"
],
"Description": "Lists all of the user roles assigned to you.",
"Usage": [
".userrolemy"
],
"Submodule": "UserRoleCommands",
"Module": "Utility",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".userrolecolor",
".urc"
],
"Description": "Changes the color of your assigned role.",
"Usage": [
".userrolecolor @Role #ff0000"
],
"Submodule": "UserRoleCommands",
"Module": "Utility",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".userrolename",
".urn"
],
"Description": "Changes the name of your assigned role.",
"Usage": [
".userrolename @Role New Role Name"
],
"Submodule": "UserRoleCommands",
"Module": "Utility",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".userroleicon",
".uri"
],
"Description": "Changes the icon of your assigned role.",
"Usage": [
".userroleicon @Role :thumbsup:"
],
"Submodule": "UserRoleCommands",
"Module": "Utility",
"Options": null,
"Requirements": []
}
],
"Xp": [
@ -8193,6 +8316,20 @@
"Options": null,
"Requirements": []
},
{
"Aliases": [
".clubbanner"
],
"Description": "Sets an image as a club banner.\nThe banner will be displayed when club information is shown.",
"Usage": [
".clubbanner https://i.imgur.com/example.png",
".clubbanner"
],
"Submodule": "Club",
"Module": "Xp",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".clubinfo"

View file

@ -111,8 +111,8 @@ twitchClientSecret:
# {0} -> shard id
# {1} -> total shards
# Linux default
# cmd: dotnet
# args: "EllieBot.dll -- {0}"
# cmd: EllieBot
# args: "{0}"
# Windows default
# cmd: EllieBot.exe
# args: "{0}"

View file

@ -1166,6 +1166,8 @@ clubadmin:
- clubadmin
clubrename:
- clubrename
clubbanner:
- clubbanner
eightball:
- eightball
- 8ball
@ -1587,3 +1589,29 @@ xprate:
- xprate
xpratereset:
- xpratereset
lyrics:
- lyrics
userroleassign:
- userroleassign
- ura
- uradd
userroleremove:
- userroleremove
- urr
- urdel
- urrm
userrolelist:
- userrolelist
- url
userrolemy:
- userrolemy
- urm
userrolecolor:
- userrolecolor
- urc
userrolename:
- userrolename
- urn
userroleicon:
- userroleicon
- uri

View file

@ -3694,6 +3694,17 @@ clubicon:
params:
- url:
desc: "The URL of an image file to use as the club icon."
clubbanner:
desc: |-
Sets an image as a club banner.
The banner will be displayed when club information is shown.
ex:
- 'https://i.imgur.com/example.png'
- ''
params:
- { }
- url:
desc: "URL to the image to set as a club banner."
clubapps:
desc: Shows the list of users who have applied to your club. Paginated. You must be club owner to use this command.
ex:
@ -4978,3 +4989,82 @@ xpratereset:
- { }
- channel:
desc: "The channel to reset the rate for."
lyrics:
desc: |-
Looks up lyrics for a song. Very hit or miss.
ex:
- 'biri biri'
params:
- song:
desc: "The song to look up lyrics for."
userroleassign:
desc: |-
Assigns a role to a user that can later be modified by that user.
ex:
- '@User @Role'
params:
- user:
desc: 'The user to assign the role to.'
role:
desc: 'The role to assign.'
userroleremove:
desc: |-
Removes a previously assigned role from a user.
ex:
- '@User @Role'
params:
- user:
desc: 'The user to remove the role from.'
role:
desc: 'The role to remove.'
userrolelist:
desc: |-
Lists all user roles in the server, or for a specific user.
ex:
- ''
- '@User'
params:
- { }
- user:
desc: 'The user whose roles to list.'
userrolemy:
desc: |-
Lists all of the user roles assigned to you.
ex:
- ''
params:
- { }
userrolecolor:
desc: |-
Changes the color of your assigned role.
ex:
- '@Role #ff0000'
params:
- role:
desc: 'The assigned role to change the color of.'
color:
desc: 'The new color for the role in hex format.'
userroleicon:
desc: |-
Changes the icon of your assigned role.
ex:
- '@Role :thumbsup:'
params:
- role:
desc: 'The assigned role to change the icon of.'
imageUrl:
desc: 'The image url to be used as a new icon for the role.'
- role:
desc: 'The assigned role to change the icon of.'
serverEmoji:
desc: 'The server emoji to be used as a new icon for the role.'
userrolename:
desc: |-
Changes the name of your assigned role.
ex:
- '@Role New Role Name'
params:
- role:
desc: 'The assigned role to rename.'
name:
desc: 'The new name for the role.'

View file

@ -887,6 +887,8 @@
"club_icon_invalid_filetype": "Specified image has an invalid filetype. Make sure you're specifying a direct image url.",
"club_icon_url_format": "You must specify an absolute image url.",
"club_icon_set": "New club icon set.",
"club_banner_set": "New club banner set.",
"club_banner_reset": "Club banner has been reset.",
"club_bans_for": "Bans for {0} club",
"club_apps_for": "Applicants for {0} club",
"club_leaderboard": "Club leaderboard - page {0}",
@ -1178,5 +1180,29 @@
"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"
"xp_rate_no_gain": "No xp gain",
"no_lyrics_found": "No lyrics found.",
"userrole_not_found": "Role not found or not assigned to you.",
"userrole_assigned": "{0} has been assigned the role {1}.",
"userrole_removed": "{0} has been removed from the role {1}.",
"userrole_none": "No user roles have been assigned in this server.",
"userrole_none_user": "{0} has no assigned user roles.",
"userrole_list_title": "User Roles in this server",
"userrole_list_for_user": "{0}'s User Roles",
"userrole_no_permission": "You don't have this role assigned as a user role.",
"userrole_no_user_roles": "You have no assigned user roles.",
"userrole_your_roles_title": "Your User Roles",
"userrole_your_roles_footer": "You may customize these roles",
"userrole_color_success": "The color of {0} has been changed to {1}.",
"userrole_color_fail": "Failed to set the role color. Make sure you're using a valid hex color code (e.g., #FF0000).",
"userrole_color_discord_fail": "Failed to update the role color in Discord. The change was saved in the database.",
"userrole_name_success": "The name of your role has been changed to {0}.",
"userrole_name_fail": "Failed to set the role name.",
"userrole_name_invalid": "The role name must not be empty and must be less than 100 characters.",
"userrole_name_discord_fail": "Failed to update the role name in Discord. The change was saved in the database.",
"userrole_icon_success": "The icon for {0} has been saved.",
"userrole_icon_fail": "Failed to set the role icon.",
"userrole_icon_invalid": "The role icon cannot be empty.",
"userrole_hierarchy_error": "You can't assign or modify roles that are higher than or equal to your, or bots highest role.",
"userrole_role_not_exists": "That role doesn't exist."
}