diff --git a/Numerous.sln.DotSettings b/Numerous.sln.DotSettings index 3822a76..b3537fb 100644 --- a/Numerous.sln.DotSettings +++ b/Numerous.sln.DotSettings @@ -47,7 +47,7 @@ ERROR WARNING WARNING - WARNING + DO_NOT_SHOW WARNING WARNING WARNING @@ -66,6 +66,8 @@ True ShowAndRun 1 + True + 70 ALWAYS NEVER ALWAYS diff --git a/Numerous/ApiClients/Osu/Extensions.cs b/Numerous/ApiClients/Osu/Extensions.cs new file mode 100644 index 0000000..0b8e1dc --- /dev/null +++ b/Numerous/ApiClients/Osu/Extensions.cs @@ -0,0 +1,29 @@ +// Copyright (C) Pasi4K5 +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with this program. If not, see . + +using Numerous.ApiClients.Osu.Models; + +namespace Numerous.ApiClients.Osu; + +public static class Extensions +{ + public static bool IsRankedMapper(this OsuUser user) + { + return user.RankedBeatmapsetCount > 0 + || user.GuestBeatmapsetCount > 0; + } + + public static bool IsLovedMapper(this OsuUser user) + { + return user.LovedBeatmapsetCount > 0; + } + + public static bool IsUnrankedMapper(this OsuUser user) + { + return + (user.GraveyardBeatmapsetCount > 0 || user.PendingBeatmapsetCount > 0) + && !user.IsRankedMapper(); + } +} diff --git a/Numerous/ApiClients/Osu/Models/OsuUser.cs b/Numerous/ApiClients/Osu/Models/OsuUser.cs index 7a4f818..9a0fa31 100644 --- a/Numerous/ApiClients/Osu/Models/OsuUser.cs +++ b/Numerous/ApiClients/Osu/Models/OsuUser.cs @@ -7,7 +7,7 @@ namespace Numerous.ApiClients.Osu.Models; -public record struct OsuUser +public record OsuUser { [JsonProperty("id")] public uint Id { get; init; } @@ -16,5 +16,34 @@ public record struct OsuUser public bool IsBot { get; init; } [JsonProperty("username")] - public string Username { get; init; } + public string Username { get; init; } = null!; + + [JsonProperty("groups")] + public OsuUserGroup[]? Groups { get; set; } = Array.Empty(); + + [JsonProperty("graveyard_beatmapset_count")] + public uint GraveyardBeatmapsetCount { get; init; } + + [JsonProperty("guest_beatmapset_count")] + public uint GuestBeatmapsetCount { get; init; } + + [JsonProperty("loved_beatmapset_count")] + public uint LovedBeatmapsetCount { get; init; } + + [JsonProperty("pending_beatmapset_count")] + public uint PendingBeatmapsetCount { get; init; } + + [JsonProperty("ranked_beatmapset_count")] + public uint RankedBeatmapsetCount { get; init; } + + public OsuUserGroup[] GetGroups() + { + return Groups ?? Array.Empty(); + } +} + +public sealed record OsuUserExtended : OsuUser +{ + [JsonProperty("discord")] + public string DiscordUsername { get; init; } = null!; } diff --git a/Numerous/ApiClients/Osu/OsuApi.Repository.cs b/Numerous/ApiClients/Osu/OsuApi.Repository.cs index bb91d86..dcbc29e 100644 --- a/Numerous/ApiClients/Osu/OsuApi.Repository.cs +++ b/Numerous/ApiClients/Osu/OsuApi.Repository.cs @@ -25,6 +25,14 @@ public partial class OsuApi return await RequestRefAsync($"users/{user}", ("key", "id")); } + /// + /// Prioritizes the user ID over the username. + /// + public async Task GetUserAsync(string user) + { + return await RequestRefAsync($"users/{user}"); + } + public async Task GetBeatmapsetAsync(uint id) { return await RequestValAsync($"beatmapsets/{id}"); diff --git a/Numerous/ApiClients/Osu/OsuUserGroup.cs b/Numerous/ApiClients/Osu/OsuUserGroup.cs new file mode 100644 index 0000000..4c6a2c3 --- /dev/null +++ b/Numerous/ApiClients/Osu/OsuUserGroup.cs @@ -0,0 +1,26 @@ +// Copyright (C) Pasi4K5 +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with this program. If not, see . + +using Discord.Interactions; + +namespace Numerous.ApiClients.Osu; + +public enum OsuUserGroup +{ + [ChoiceDisplay("Unranked Mapper")] UnrankedMapper = -1, + [ChoiceDisplay("Ranked Mapper")] RankedMapper = -2, + [ChoiceDisplay("Loved Mapper")] LovedMapper = -3, + [ChoiceDisplay("GMT")] GlobalModerationTeam = 4, + [ChoiceDisplay("NAT")] NominationAssessmentTeam = 7, + [ChoiceDisplay("Developers")] Developers = 11, + [ChoiceDisplay("Community Contributors")] OsuAlumin = 16, + [ChoiceDisplay("Technical Support Team")] TechnicalSupportTeam = 17, + [ChoiceDisplay("Beatmap Nominators")] BeatmapNominators = 28, + [ChoiceDisplay("Project Loved")] ProjectLoved = 31, + [ChoiceDisplay("Beatmap Nominators (Probationary)")] BeatmapNominatorsProbationary = 32, + [ChoiceDisplay("ppy")] Ppy = 33, + [ChoiceDisplay("Featured Artists")] FeaturedArtists = 35, + [ChoiceDisplay("Beatmap Spotlight Curators")] BeatmapSpotlightCurators = 48, +} diff --git a/Numerous/Database/DbManager.cs b/Numerous/Database/DbManager.cs index 983e70d..61f9d44 100644 --- a/Numerous/Database/DbManager.cs +++ b/Numerous/Database/DbManager.cs @@ -43,20 +43,27 @@ public DbManager(ConfigManager configManager) } } + public async Task EnsureUserExistsAsync(ulong id) + { + await GetUserAsync(id); + } + public async Task GetUserAsync(ulong id) { var user = await Users.Find(x => x.Id == id).FirstOrDefaultAsync(); - if (user is null) + if (user is not null) { - user = new DbUser - { - Id = id, - }; - - await Users.InsertOneAsync(user); + return user; } + user = new DbUser + { + Id = id, + }; + + await Users.InsertOneAsync(user); + return user; } } diff --git a/Numerous/Database/Entities/DbUser.cs b/Numerous/Database/Entities/DbUser.cs index 992d63d..ac2638b 100644 --- a/Numerous/Database/Entities/DbUser.cs +++ b/Numerous/Database/Entities/DbUser.cs @@ -10,5 +10,6 @@ public sealed record DbUser : DbEntity // For future osu!-related features // public DbOsuUser? OsuUser { get; init; } + public uint? OsuId { get; set; } public string? TimeZone { get; set; } } diff --git a/Numerous/Database/Entities/GuildOptions.cs b/Numerous/Database/Entities/GuildOptions.cs index c956b4b..717299b 100644 --- a/Numerous/Database/Entities/GuildOptions.cs +++ b/Numerous/Database/Entities/GuildOptions.cs @@ -3,6 +3,8 @@ // This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. // You should have received a copy of the GNU General Public License along with this program. If not, see . +using Numerous.ApiClients.Osu; + namespace Numerous.Database.Entities; // TODO: Remove comment @@ -11,6 +13,7 @@ public sealed record GuildOptions : DbEntity { public bool TrackMessages { get; init; } public bool TrackMemberCount { get; init; } + public ICollection OsuRoles { get; init; } = []; public TrackingOptions[] PlayerTrackingOptions { get; init; } = Array.Empty(); @@ -19,4 +22,10 @@ public record struct TrackingOptions public ulong DiscordId { get; init; } public bool TrackPlayer { get; init; } } + + public record struct OsuRole + { + public OsuUserGroup Group { get; init; } + public ulong RoleId { get; init; } + } } diff --git a/Numerous/Discord/Commands/CommandModule.cs b/Numerous/Discord/Commands/CommandModule.cs index 41010f4..5501e15 100644 --- a/Numerous/Discord/Commands/CommandModule.cs +++ b/Numerous/Discord/Commands/CommandModule.cs @@ -30,9 +30,10 @@ protected async Task RespondWithEmbedAsync(string message, ResponseType type = R ); } - protected async Task FollowupWithEmbedAsync(string message, ResponseType type = ResponseType.Info) + protected async Task FollowupWithEmbedAsync(string title = "", string message = "", ResponseType type = ResponseType.Info) { await FollowupAsync(embed: new EmbedBuilder() + .WithTitle(title) .WithColor(GetTypeColor(type)) .WithDescription(message) .Build() diff --git a/Numerous/Discord/Commands/ConfigCommandModule.cs b/Numerous/Discord/Commands/ConfigCommandModule.cs new file mode 100644 index 0000000..448e25b --- /dev/null +++ b/Numerous/Discord/Commands/ConfigCommandModule.cs @@ -0,0 +1,49 @@ +// Copyright (C) Pasi4K5 +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with this program. If not, see . + +using Discord; +using Discord.Interactions; +using JetBrains.Annotations; +using Numerous.ApiClients.Osu; + +namespace Numerous.Discord.Commands; + +[Group("config", "Configuration commands")] +[DefaultMemberPermissions(GuildPermission.Administrator)] +public sealed class ConfigCommandModule : CommandModule +{ + [Group("role", "Role configuration commands")] + public sealed class RoleCommandModule(OsuVerifier verifier) : CommandModule + { + [UsedImplicitly] + [SlashCommand("set", "Configures which role to assign to which users.")] + public async Task SetRole( + [Summary("group", "The group to set the role for.")] OsuUserGroup group, + [Summary("role", "The role to assign to users in the group.")] IRole role + ) + { + await verifier.SetRoleAsync(Context.Guild, group, role); + + await RespondWithEmbedAsync( + $"Set role for group {group} to {role.Mention}.", + ResponseType.Success + ); + } + + [UsedImplicitly] + [SlashCommand("remove", "Stops automatically assigning the given role.")] + public async Task RemoveRole( + [Summary("group", "The group to remove the role for.")] OsuUserGroup group + ) + { + await verifier.RemoveRoleAsync(Context.Guild, group); + + await RespondWithEmbedAsync( + $"Removed role for group {group}.", + ResponseType.Success + ); + } + } +} diff --git a/Numerous/Discord/Commands/InteractionHandler.cs b/Numerous/Discord/Commands/InteractionHandler.cs index 958bc0f..c939ec0 100644 --- a/Numerous/Discord/Commands/InteractionHandler.cs +++ b/Numerous/Discord/Commands/InteractionHandler.cs @@ -22,7 +22,7 @@ public sealed class InteractionHandler( ConfigManager configManager ) : IHostedService { - public async Task StartAsync(CancellationToken cancellationToken) + public Task StartAsync(CancellationToken cancellationToken) { var cfg = configManager.Get(); @@ -40,8 +40,11 @@ public async Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; }; - client.Ready += cfg.GuildMode - ? async () => + client.Ready += async () => + { + await interactions.AddModulesAsync(Assembly.GetEntryAssembly(), services); + + if (cfg.GuildMode) { foreach (var cmd in await client.GetGlobalApplicationCommandsAsync()) { @@ -53,7 +56,7 @@ public async Task StartAsync(CancellationToken cancellationToken) await interactions.RegisterCommandsToGuildAsync(guildId); } } - : async () => + else { foreach (var guild in await client.Rest.GetGuildsAsync()) { @@ -61,10 +64,11 @@ public async Task StartAsync(CancellationToken cancellationToken) } await interactions.RegisterCommandsGloballyAsync(); - }; + } + }; client.InteractionCreated += OnInteractionCreatedAsync; - await interactions.AddModulesAsync(Assembly.GetEntryAssembly(), services); + return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) diff --git a/Numerous/Discord/Commands/ReminderCommandModule.cs b/Numerous/Discord/Commands/ReminderCommandModule.cs index 6553dc4..2bcb773 100644 --- a/Numerous/Discord/Commands/ReminderCommandModule.cs +++ b/Numerous/Discord/Commands/ReminderCommandModule.cs @@ -103,8 +103,8 @@ public async Task AtCommand( if (timeZoneId is null) { await FollowupWithEmbedAsync( - "To use this command, please set your time zone with `/settimezone` first.", - ResponseType.Error + message: "To use this command, please set your time zone with `/settimezone` first.", + type: ResponseType.Error ); return; @@ -120,8 +120,8 @@ await FollowupWithEmbedAsync( if (timestamp is null) { await FollowupWithEmbedAsync( - "The specified time must be at least 10 seconds in the future.", - ResponseType.Error + message: "The specified time must be at least 10 seconds in the future.", + type: ResponseType.Error ); return; @@ -143,7 +143,7 @@ await FollowupAsync(embed: } catch (ArgumentOutOfRangeException) { - await FollowupWithEmbedAsync("The specified time is invalid.", ResponseType.Error); + await FollowupWithEmbedAsync("The specified time is invalid.", type: ResponseType.Error); } } @@ -281,7 +281,7 @@ int index if (index < 1 || index > reminders.Count) { - await FollowupWithEmbedAsync("There is no reminder with that index.", ResponseType.Error); + await FollowupWithEmbedAsync("There is no reminder with that index.", type: ResponseType.Error); return; } diff --git a/Numerous/Discord/Commands/VerifyCommandModule.cs b/Numerous/Discord/Commands/VerifyCommandModule.cs new file mode 100644 index 0000000..802110f --- /dev/null +++ b/Numerous/Discord/Commands/VerifyCommandModule.cs @@ -0,0 +1,82 @@ +// Copyright (C) Pasi4K5 +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with this program. If not, see . + +using Discord.Interactions; +using JetBrains.Annotations; +using Numerous.ApiClients.Osu; + +namespace Numerous.Discord.Commands; + +public sealed class VerifyCommandModule(OsuApi osu, OsuVerifier verifier) : CommandModule +{ + [UsedImplicitly] + [SlashCommand("verify", "Verifies your osu! account.")] + public async Task Verify( + [Summary("user", "Your osu! username or user ID")] + string username + ) + { + await DeferAsync(); + + var guildUser = Context.Guild.GetUser(Context.User.Id); + + if (await verifier.UserIsVerifiedAsync(guildUser)) + { + await FollowupWithEmbedAsync( + "You are already verified.", + type: ResponseType.Info + ); + + return; + } + + var osuUser = await osu.GetUserAsync(username); + + if (osuUser is null) + { + await FollowupWithEmbedAsync( + "User not found.", + type: ResponseType.Error + ); + + return; + } + + if (!string.Equals(osuUser.DiscordUsername, Context.User.Username, StringComparison.OrdinalIgnoreCase)) + { + await FollowupWithEmbedAsync( + "Verification failed", + "Please make sure that the provided osu! username or ID is correct and that " + + $"the \"Discord\" field on your osu! profile matches your Discord username (\"{Context.User.Username}\").\n" + + "You can change that in your [osu! account settings](https://osu.ppy.sh/home/account/edit) under \"Profile\"\u2192\"discord\".\n" + + "After the verification, you can remove it again if you want to.\n\n" + + "**Important:**\n" + + "* Do not enter another person's Discord username in your osu! profile. Otherwise they will be able to verify as you.\n" + + "* You can only perform this verification once.", + ResponseType.Error + ); + + return; + } + + if (await verifier.OsuUserIsVerifiedAsync(osuUser)) + { + await FollowupWithEmbedAsync( + message: "This osu! account is already verified. " + + "If you believe this is a mistake or you have lost access to your Discord account, please contact a server administrator.", + type: ResponseType.Error + ); + + return; + } + + await verifier.VerifyUserAsync(Context.Guild.GetUser(Context.User.Id), osuUser); + + await FollowupWithEmbedAsync( + "Verification successful", + type: ResponseType.Success + ); + } +} diff --git a/Numerous/Discord/OsuVerifier.cs b/Numerous/Discord/OsuVerifier.cs new file mode 100644 index 0000000..cf839bf --- /dev/null +++ b/Numerous/Discord/OsuVerifier.cs @@ -0,0 +1,181 @@ +// Copyright (C) Pasi4K5 +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with this program. If not, see . + +using Coravel; +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using MongoDB.Driver; +using Numerous.ApiClients.Osu; +using Numerous.ApiClients.Osu.Models; +using Numerous.Database; +using Numerous.Database.Entities; +using Numerous.DependencyInjection; + +namespace Numerous.Discord; + +[SingletonService] +public sealed class OsuVerifier(IHost host, DiscordSocketClient discord, DbManager db, OsuApi osu) +{ + public Task StartAsync() + { + host.Services.UseScheduler(scheduler => scheduler.ScheduleAsync(async () => + { + foreach (var guild in discord.Guilds) + { + var dbUsers = await db.Users.FindAsync(x => x.OsuId != null); + + await dbUsers.ForEachAsync(async dbUser => + { + var guildUser = guild.GetUser(dbUser.Id); + + if (guildUser is null) + { + return; + } + + await AssignRolesAsync(guildUser); + }); + } + }).EveryFiveSeconds()); + + return Task.CompletedTask; + } + + public async Task UserIsVerifiedAsync(IGuildUser guildUser) + { + var user = await db.GetUserAsync(guildUser.Id); + + return user.OsuId is not null; + } + + public async Task OsuUserIsVerifiedAsync(OsuUser osuUser) + { + return await db.Users.Find(x => x.OsuId == osuUser.Id).AnyAsync(); + } + + public async Task VerifyUserAsync(IGuildUser guildUser, OsuUser osuUser) + { + await db.EnsureUserExistsAsync(guildUser.Id); + + await db.Users.UpdateOneAsync( + Builders.Filter.Eq(x => x.Id, guildUser.Id), + Builders.Update.Set(x => x.OsuId, osuUser.Id) + ); + + await AssignRolesAsync(guildUser); + } + + public async Task SetRoleAsync(IGuild guild, OsuUserGroup group, IRole role) + { + var guildConfig = await (await db.GuildOptions.FindAsync(x => x.Id == guild.Id)).SingleAsync(); + + guildConfig.OsuRoles.Remove(guildConfig.OsuRoles.FirstOrDefault(x => x.Group == group)); + guildConfig.OsuRoles.Add(new GuildOptions.OsuRole + { + Group = group, + RoleId = role.Id, + }); + + await db.GuildOptions.UpdateOneAsync( + Builders.Filter.Eq(x => x.Id, guild.Id), + Builders.Update.Set(x => x.OsuRoles, guildConfig.OsuRoles) + ); + } + + public async Task RemoveRoleAsync(IGuild guild, OsuUserGroup group) + { + var guildConfig = await (await db.GuildOptions.FindAsync(x => x.Id == guild.Id)).SingleAsync(); + + guildConfig.OsuRoles.Remove(guildConfig.OsuRoles.FirstOrDefault(x => x.Group == group)); + + await db.GuildOptions.UpdateOneAsync( + Builders.Filter.Eq(x => x.Id, guild.Id), + Builders.Update.Set(x => x.OsuRoles, guildConfig.OsuRoles) + ); + } + + private async Task AssignRolesAsync(IGuildUser guildUser) + { + var osuUser = await GetOsuUserAsync(guildUser); + + if (osuUser is null) + { + return; + } + + var guildConfig = await (await db.GuildOptions.FindAsync(x => x.Id == guildUser.GuildId)).SingleAsync(); + + foreach (var osuRole in guildConfig.OsuRoles.Where(osuRole => osuRole.Group > 0)) + { + var role = guildUser.Guild.GetRole(osuRole.RoleId); + + if (osuUser.GetGroups().Contains(osuRole.Group)) + { + if (role is not null && !guildUser.RoleIds.Contains(osuRole.RoleId)) + { + await guildUser.AddRoleAsync(role); + } + } + else if (role is not null && guildUser.RoleIds.Contains(osuRole.RoleId)) + { + await guildUser.RemoveRoleAsync(role); + } + } + + await Task.WhenAll( + AssignRoleAsync(OsuUserGroup.UnrankedMapper, osuUser.IsUnrankedMapper()), + AssignRoleAsync(OsuUserGroup.RankedMapper, osuUser.IsRankedMapper()), + AssignRoleAsync(OsuUserGroup.ProjectLoved, osuUser.IsLovedMapper()) + ); + + return; + + async Task AssignRoleAsync(OsuUserGroup group, bool add) + { + var roleId = guildConfig?.OsuRoles.FirstOrDefault(osuRole => osuRole.Group == group).RoleId; + + if (roleId is null) + { + return; + } + + var role = guildUser.Guild.GetRole(roleId.Value); + + if (role is not null) + { + await (add ? AddRoleAsync(role) : RemoveRoleAsync(role)); + } + } + + async Task AddRoleAsync(IRole role) + { + if (!guildUser.RoleIds.Contains(role.Id)) + { + await guildUser.AddRoleAsync(role); + } + } + + async Task RemoveRoleAsync(IRole role) + { + if (guildUser.RoleIds.Contains(role.Id)) + { + await guildUser.RemoveRoleAsync(role); + } + } + } + + private async Task GetOsuUserAsync(IUser discordUser) + { + var user = await db.GetUserAsync(discordUser.Id); + + if (user.OsuId is null) + { + return null; + } + + return await osu.GetUserAsync(user.OsuId.Value.ToString()); + } +} diff --git a/Numerous/Services/Startup.cs b/Numerous/Services/Startup.cs index 6c02e1b..09af50d 100644 --- a/Numerous/Services/Startup.cs +++ b/Numerous/Services/Startup.cs @@ -20,7 +20,8 @@ public sealed class Startup( DiscordSocketClient discordClient, ConfigManager cfgManager, DbManager dbManager, - ReminderService reminderService + ReminderService reminderService, + OsuVerifier verifier ) : IHostedService { private Config Cfg => cfgManager.Get(); @@ -46,6 +47,7 @@ await dbManager.GuildOptions.InsertOneAsync(new GuildOptions } await reminderService.StartAsync(cancellationToken); + await verifier.StartAsync(); } public async Task StopAsync(CancellationToken cancellationToken)