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

@ -373,18 +373,31 @@ public partial class Gambling : GamblingModule<GamblingService>
return;
}
List<CurrencyTransaction> trs;
await using (var uow = _db.GetDbContext())
{
trs = await uow.Set<CurrencyTransaction>().GetPageFor(userId, page);
}
var embed = CreateEmbed()
.WithTitle(GetText(strs.transactions(
((SocketGuild)ctx.Guild)?.GetUser(userId)?.ToString()
?? $"{userId}")))
.WithOkColor();
int count;
await using (var uow = _db.GetDbContext())
{
count = await uow.Set<CurrencyTransaction>()
.GetCountFor(userId);
}
await Response()
.Paginated()
.PageItems(async (curPage) =>
{
await using var uow = _db.GetDbContext();
return await uow.Set<CurrencyTransaction>()
.GetPageFor(userId, curPage);
})
.PageSize(15)
.TotalElements(count)
.Page((trs, _) =>
{
var sb = new StringBuilder();
foreach (var tr in trs)
{
@ -406,8 +419,8 @@ public partial class Gambling : GamblingModule<GamblingService>
}
embed.WithDescription(sb.ToString());
embed.WithFooter(GetText(strs.page(page + 1)));
await Response().Embed(embed).SendAsync();
return Task.FromResult(embed);
}).SendAsync();
}
private static string GetFormattedCurtrDate(CurrencyTransaction ct)

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()
{
@ -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

@ -161,9 +161,11 @@ public partial class Utility : EllieModule
CacheMode.CacheOnly
);
users = role is null
users = (role is null
? users
: users.Where(u => u.RoleIds.Contains(role.Id)).ToList();
: users.Where(u => u.RoleIds.Contains(role.Id)))
.OrderBy(x => x.DisplayName)
.ToList();
var roleUsers = new List<string>(users.Count);
@ -790,7 +792,9 @@ 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;

View file

@ -108,6 +108,31 @@ 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);
@ -154,7 +179,10 @@ public partial class Xp
})));
if (Uri.IsWellFormedUriString(club.ImageUrl, UriKind.Absolute))
return embed.WithThumbnailUrl(club.ImageUrl);
embed.WithThumbnailUrl(club.ImageUrl);
if (Uri.IsWellFormedUriString(club.BannerUrl, UriKind.Absolute))
embed.WithImageUrl(club.BannerUrl);
return embed;
})
@ -415,7 +443,7 @@ public partial class Xp
? "-"
: desc;
var eb = _sender.CreateEmbed()
var eb = CreateEmbed()
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.club_desc_update))
.WithOkColor()

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."
}