diff --git a/.gitignore b/.gitignore index fbb5781..4d5d908 100644 --- a/.gitignore +++ b/.gitignore @@ -371,4 +371,10 @@ site/ .aider.* PROMPT.md -.aider* \ No newline at end of file +.aider* +.windsurfrules + +## Python pip/env files +Pipfile +Pipfile.lock +.venv \ No newline at end of file diff --git a/src/EllieBot/Migrations/PostgreSql/20250310101121_userroles.sql b/src/EllieBot/Migrations/PostgreSql/20250310101121_userroles.sql new file mode 100644 index 0000000..e0d757e --- /dev/null +++ b/src/EllieBot/Migrations/PostgreSql/20250310101121_userroles.sql @@ -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; diff --git a/src/EllieBot/Migrations/PostgreSql/20250228044209_init.Designer.cs b/src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs similarity index 99% rename from src/EllieBot/Migrations/PostgreSql/20250228044209_init.Designer.cs rename to src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs index 02ad1c2..8ecf1ff 100644 --- a/src/EllieBot/Migrations/PostgreSql/20250228044209_init.Designer.cs +++ b/src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace EllieBot.Migrations.PostgreSql { [DbContext(typeof(PostgreSqlContext))] - [Migration("20250228044209_init")] + [Migration("20250310101144_init")] partial class init { /// <inheritdoc /> @@ -3444,6 +3444,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") diff --git a/src/EllieBot/Migrations/PostgreSql/20250228044209_init.cs b/src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs similarity index 99% rename from src/EllieBot/Migrations/PostgreSql/20250228044209_init.cs rename to src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs index a99bf07..22e4c85 100644 --- a/src/EllieBot/Migrations/PostgreSql/20250228044209_init.cs +++ b/src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs @@ -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 @@ -2131,6 +2144,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 +2515,9 @@ namespace EllieBot.Migrations.PostgreSql migrationBuilder.DropTable( name: "userfishstats"); + migrationBuilder.DropTable( + name: "userrole"); + migrationBuilder.DropTable( name: "userxpstats"); diff --git a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs index e40eb15..cbc6220 100644 --- a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs +++ b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs @@ -3441,6 +3441,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") diff --git a/src/EllieBot/Migrations/Sqlite/20250310101118_userroles.sql b/src/EllieBot/Migrations/Sqlite/20250310101118_userroles.sql new file mode 100644 index 0000000..f5d2c57 --- /dev/null +++ b/src/EllieBot/Migrations/Sqlite/20250310101118_userroles.sql @@ -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; diff --git a/src/EllieBot/Migrations/Sqlite/20250228044206_init.Designer.cs b/src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs similarity index 99% rename from src/EllieBot/Migrations/Sqlite/20250228044206_init.Designer.cs rename to src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs index f1d82e7..962bbd2 100644 --- a/src/EllieBot/Migrations/Sqlite/20250228044206_init.Designer.cs +++ b/src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs @@ -11,7 +11,7 @@ using EllieBot.Db; namespace EllieBot.Migrations.Sqlite { [DbContext(typeof(SqliteContext))] - [Migration("20250228044206_init")] + [Migration("20250310101142_init")] partial class init { /// <inheritdoc /> @@ -2563,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") diff --git a/src/EllieBot/Migrations/Sqlite/20250228044206_init.cs b/src/EllieBot/Migrations/Sqlite/20250310101142_init.cs similarity index 99% rename from src/EllieBot/Migrations/Sqlite/20250228044206_init.cs rename to src/EllieBot/Migrations/Sqlite/20250310101142_init.cs index 7e5aeba..3ac77bd 100644 --- a/src/EllieBot/Migrations/Sqlite/20250228044206_init.cs +++ b/src/EllieBot/Migrations/Sqlite/20250310101142_init.cs @@ -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 @@ -2133,6 +2146,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 +2517,9 @@ namespace EllieBot.Migrations.Sqlite migrationBuilder.DropTable( name: "UserFishStats"); + migrationBuilder.DropTable( + name: "UserRole"); + migrationBuilder.DropTable( name: "UserXpStats"); diff --git a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs index 77fa0b9..fa6e48c 100644 --- a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs +++ b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs @@ -2560,6 +2560,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") diff --git a/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs b/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs index afc172d..38c392e 100644 --- a/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs +++ b/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs @@ -20,7 +20,6 @@ public partial class Utility # txt: Quote text """; - private static readonly ISerializer _exportSerializer = new SerializerBuilder() .WithEventEmitter(args => new MultilineScalarFlowStyleEmitter(args)) diff --git a/src/EllieBot/Modules/Utility/UserRole/IDiscordRoleManager.cs b/src/EllieBot/Modules/Utility/UserRole/IDiscordRoleManager.cs new file mode 100644 index 0000000..41b98b4 --- /dev/null +++ b/src/EllieBot/Modules/Utility/UserRole/IDiscordRoleManager.cs @@ -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 + ); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/UserRole/IUserRoleService.cs b/src/EllieBot/Modules/Utility/UserRole/IUserRoleService.cs new file mode 100644 index 0000000..399c604 --- /dev/null +++ b/src/EllieBot/Modules/Utility/UserRole/IUserRoleService.cs @@ -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); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRole.cs b/src/EllieBot/Modules/Utility/UserRole/UserRole.cs new file mode 100644 index 0000000..1c667b3 --- /dev/null +++ b/src/EllieBot/Modules/Utility/UserRole/UserRole.cs @@ -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 }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs b/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs new file mode 100644 index 0000000..1952001 --- /dev/null +++ b/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs @@ -0,0 +1,253 @@ +using SixLabors.ImageSharp.PixelFormats; + +namespace EllieBot.Modules.Utility.UserRole; + +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 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(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRoleDiscordManager.cs b/src/EllieBot/Modules/Utility/UserRole/UserRoleDiscordManager.cs new file mode 100644 index 0000000..9d60260 --- /dev/null +++ b/src/EllieBot/Modules/Utility/UserRole/UserRoleDiscordManager.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRoleService.cs b/src/EllieBot/Modules/Utility/UserRole/UserRoleService.cs new file mode 100644 index 0000000..d500cb0 --- /dev/null +++ b/src/EllieBot/Modules/Utility/UserRole/UserRoleService.cs @@ -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); + } +} \ No newline at end of file diff --git a/src/EllieBot/strings/aliases.yml b/src/EllieBot/strings/aliases.yml index 8f2929a..554ad0f 100644 --- a/src/EllieBot/strings/aliases.yml +++ b/src/EllieBot/strings/aliases.yml @@ -1588,4 +1588,28 @@ xprate: xpratereset: - xpratereset lyrics: - - lyrics \ No newline at end of file + - 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 \ No newline at end of file diff --git a/src/EllieBot/strings/commands/commands.en-US.yml b/src/EllieBot/strings/commands/commands.en-US.yml index e71adcf..d8ac450 100644 --- a/src/EllieBot/strings/commands/commands.en-US.yml +++ b/src/EllieBot/strings/commands/commands.en-US.yml @@ -4985,4 +4985,75 @@ lyrics: - 'biri biri' params: - song: - desc: "The song to look up lyrics for." \ No newline at end of file + 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.' + icon: + desc: 'The new icon for the role.' + - role: + desc: 'The assigned role to change the icon of.' + emote: + desc: 'The server emoji 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.' \ No newline at end of file diff --git a/src/EllieBot/strings/responses/responses.en-US.json b/src/EllieBot/strings/responses/responses.en-US.json index cd15917..b54e7ec 100644 --- a/src/EllieBot/strings/responses/responses.en-US.json +++ b/src/EllieBot/strings/responses/responses.en-US.json @@ -1179,5 +1179,28 @@ "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", - "no_lyrics_found": "No lyrics found." + "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 highest role.", + "userrole_role_not_exists": "That role doesn't exist." }