diff --git a/Docker/run/compose/mare-standalone.yml b/Docker/run/compose/mare-standalone.yml index a49e82f..cfbd3ae 100644 --- a/Docker/run/compose/mare-standalone.yml +++ b/Docker/run/compose/mare-standalone.yml @@ -51,6 +51,7 @@ services: MareSynchronos__DiscordBotToken: "${DEV_MARE_DISCORDTOKEN}" MareSynchronos__DiscordChannelForMessages: "${DEV_MARE_DISCORDCHANNEL}" MareSynchronos__DiscordChannelForReports: "${DEV_MARE_DISCORDCHANNEL}" + MareSynchronos__DiscordChannelForCommands: "${DEV_MARE_DISCORDCHANNEL}" DOTNET_USE_POLLING_FILE_WATCHER: 1 volumes: - ../config/standalone/services-standalone.json:/opt/MareSynchronosServices/appsettings.json diff --git a/Docker/run/config/standalone/services-standalone.json b/Docker/run/config/standalone/services-standalone.json index 56365a4..c844ef9 100644 --- a/Docker/run/config/standalone/services-standalone.json +++ b/Docker/run/config/standalone/services-standalone.json @@ -33,8 +33,10 @@ "MainServerGrpcAddress": "http://mare-server:6005/", "DiscordBotToken": "", "DiscordChannelForMessages": "", + "DiscordChannelForCommands": "", "Jwt": "teststringteststringteststringteststringteststringteststringteststringteststringteststringteststring", - "RedisConnectionString": "redis,password=secretredispassword" + "RedisConnectionString": "redis,password=secretredispassword", + "VanityRoles": [] }, "AllowedHosts": "*", "Kestrel": { diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBot.cs b/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBot.cs index 1a8f08f..fbff191 100644 --- a/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBot.cs +++ b/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBot.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; using System.Text; +using System.Threading.Channels; namespace MareSynchronosServices.Discord; @@ -54,7 +55,9 @@ internal class DiscordBot : IHostedService if (!string.IsNullOrEmpty(token)) { _interactionModule = new InteractionService(_discordClient); + _interactionModule.Log += Log; await _interactionModule.AddModuleAsync(typeof(MareModule), _services).ConfigureAwait(false); + await _interactionModule.AddModuleAsync(typeof(MareWizardModule), _services).ConfigureAwait(false); await _discordClient.LoginAsync(TokenType.Bot, token).ConfigureAwait(false); await _discordClient.StartAsync().ConfigureAwait(false); @@ -64,10 +67,10 @@ internal class DiscordBot : IHostedService _discordClient.InteractionCreated += async (x) => { var ctx = new SocketInteractionContext(_discordClient, x); - await _interactionModule.ExecuteCommandAsync(ctx, _services); + await _interactionModule.ExecuteCommandAsync(ctx, _services).ConfigureAwait(false); }; - await _botServices.Start(); + await _botServices.Start().ConfigureAwait(false); _ = UpdateStatusAsync(); } } @@ -78,7 +81,7 @@ internal class DiscordBot : IHostedService { _discordClient.ButtonExecuted -= ButtonExecutedHandler; - await _botServices.Stop(); + await _botServices.Stop().ConfigureAwait(false); _processReportQueueCts?.Cancel(); _updateStatusCts?.Cancel(); _vanityUpdateCts?.Cancel(); @@ -197,16 +200,100 @@ internal class DiscordBot : IHostedService private async Task DiscordClient_Ready() { - var guild = (await _discordClient.Rest.GetGuildsAsync()).First(); + var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First(); await _interactionModule.RegisterCommandsToGuildAsync(guild.Id, true).ConfigureAwait(false); + await CreateOrUpdateModal(guild).ConfigureAwait(false); _ = RemoveUsersNotInVanityRole(); _ = ProcessReportsQueue(); + _ = UpdateVanityRoles(guild); + } + + private async Task UpdateVanityRoles(RestGuild guild) + { + while (!_updateStatusCts.IsCancellationRequested) + { + var vanityRoles = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.VanityRoles), Array.Empty()); + if (vanityRoles.Length != _botServices.VanityRoles.Count) + { + _botServices.VanityRoles.Clear(); + foreach (var role in vanityRoles) + { + var restrole = guild.GetRole(role); + if (restrole != null) _botServices.VanityRoles.Add(restrole); + } + } + + await Task.Delay(TimeSpan.FromSeconds(30), _updateStatusCts.Token).ConfigureAwait(false); + } + } + + private async Task CreateOrUpdateModal(RestGuild guild) + { + _logger.LogInformation("Creating Wizard: Getting Channel"); + + var discordChannelForCommands = _configurationService.GetValue(nameof(ServicesConfiguration.DiscordChannelForCommands)); + if (discordChannelForCommands == null) + { + _logger.LogWarning("Creating Wizard: No channel configured"); + return; + } + + IUserMessage? message = null; + var socketchannel = await _discordClient.GetChannelAsync(discordChannelForCommands.Value).ConfigureAwait(false) as SocketTextChannel; + var pinnedMessages = await socketchannel.GetPinnedMessagesAsync().ConfigureAwait(false); + foreach (var msg in pinnedMessages) + { + _logger.LogInformation("Creating Wizard: Checking message id {id}, author is: {author}, hasEmbeds: {embeds}", msg.Id, msg.Author.Id, msg.Embeds.Any()); + if (msg.Author.Id == _discordClient.CurrentUser.Id + && msg.Embeds.Any()) + { + message = await socketchannel.GetMessageAsync(msg.Id).ConfigureAwait(false) as IUserMessage; + break; + } + } + + _logger.LogInformation("Creating Wizard: Found message id: {id}", message?.Id ?? 0); + + await GenerateOrUpdateWizardMessage(socketchannel, message).ConfigureAwait(false); + } + + private async Task GenerateOrUpdateWizardMessage(SocketTextChannel channel, IUserMessage? prevMessage) + { + EmbedBuilder eb = new EmbedBuilder(); + eb.WithTitle("Mare Services Bot Interaction Service"); + eb.WithDescription("Press \"Start\" to interact with this bot!" + Environment.NewLine + Environment.NewLine + + "You can handle all of your Mare account needs in this server through the easy to use interactive bot prompt. Just follow the instructions!"); + eb.WithThumbnailUrl("https://raw.githubusercontent.com/Penumbra-Sync/repo/main/MareSynchronos/images/icon.png"); + var cb = new ComponentBuilder(); + cb.WithButton("Start", style: ButtonStyle.Primary, customId: "wizard-home:true", emote: Emoji.Parse("➡️")); + if (prevMessage == null) + { + var msg = await channel.SendMessageAsync(embed: eb.Build(), components: cb.Build()).ConfigureAwait(false); + await msg.PinAsync().ConfigureAwait(false); + } + else + { + await prevMessage.ModifyAsync(p => + { + p.Embed = eb.Build(); + p.Components = cb.Build(); + }).ConfigureAwait(false); + } } private Task Log(LogMessage msg) { - _logger.LogInformation("{msg}", msg); + switch (msg.Severity) + { + case LogSeverity.Critical: + case LogSeverity.Error: + _logger.LogError(msg.Exception, msg.Message); break; + case LogSeverity.Warning: + _logger.LogWarning(msg.Exception, msg.Message); break; + default: + _logger.LogInformation(msg.Message); break; + } return Task.CompletedTask; } diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBotServices.cs b/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBotServices.cs index 7c8cc94..301b97d 100644 --- a/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBotServices.cs +++ b/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBotServices.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using Discord.Rest; +using Discord.WebSocket; using MareSynchronosShared.Metrics; namespace MareSynchronosServices.Discord; @@ -8,10 +10,13 @@ public class DiscordBotServices public readonly string[] LodestoneServers = new[] { "eu", "na", "jp", "fr", "de" }; public ConcurrentDictionary DiscordLodestoneMapping = new(); public ConcurrentDictionary DiscordRelinkLodestoneMapping = new(); + public ConcurrentDictionary DiscordVerifiedUsers { get; } = new(); public ConcurrentDictionary LastVanityChange = new(); public ConcurrentDictionary LastVanityGidChange = new(); + public ConcurrentDictionary ValidInteractions { get; } = new(); + public List VanityRoles { get; set; } = new(); private readonly IServiceProvider _serviceProvider; - private CancellationTokenSource? verificationTaskCts; + private CancellationTokenSource verificationTaskCts; public DiscordBotServices(IServiceProvider serviceProvider, ILogger logger, MareMetrics metrics) { diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Delete.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Delete.cs new file mode 100644 index 0000000..243f130 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Delete.cs @@ -0,0 +1,100 @@ +using Discord.Interactions; +using Discord; +using MareSynchronosShared.Data; +using MareSynchronosShared.Utils; + +namespace MareSynchronosServices.Discord; + +public partial class MareWizardModule +{ + [ComponentInteraction("wizard-delete")] + public async Task ComponentDelete() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + EmbedBuilder eb = new(); + eb.WithTitle("Delete Account"); + eb.WithDescription("You can delete your primary or secondary UIDs here." + Environment.NewLine + Environment.NewLine + + "__Note: deleting your primary UID will delete all associated secondary UIDs as well.__" + Environment.NewLine + Environment.NewLine + + "- 1️⃣ is your primary account/UID" + Environment.NewLine + + "- 2️⃣ are all your secondary accounts/UIDs" + Environment.NewLine + + "If you are using Vanity UIDs the original UID is displayed in the second line of the account selection."); + eb.WithColor(Color.Blue); + + ComponentBuilder cb = new(); + await AddUserSelection(mareDb, cb, "wizard-delete-select").ConfigureAwait(false); + AddHome(cb); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-delete-select")] + public async Task SelectionDeleteAccount(string uid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + bool isPrimary = mareDb.Auth.Single(u => u.UserUID == uid).PrimaryUserUID == null; + EmbedBuilder eb = new(); + eb.WithTitle($"Are you sure you want to delete {uid}?"); + eb.WithDescription($"This operation is irreversible. All your pairs, joined syncshells and information stored on the service for {uid} will be " + + $"irrevocably deleted." + + (isPrimary ? (Environment.NewLine + Environment.NewLine + + "⚠️ **You are about to delete a Primary UID, all attached Secondary UIDs and their information will be deleted as well.** ⚠️") : string.Empty)); + eb.WithColor(Color.Purple); + ComponentBuilder cb = new(); + cb.WithButton("Cancel", "wizard-delete", emote: new Emoji("❌")); + cb.WithButton($"Delete {uid}", "wizard-delete-confirm:" + uid, ButtonStyle.Danger, emote: new Emoji("🗑️")); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-delete-confirm:*")] + public async Task ComponentDeleteAccountConfirm(string uid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + await RespondWithModalAsync("wizard-delete-confirm-modal:" + uid).ConfigureAwait(false); + } + + [ModalInteraction("wizard-delete-confirm-modal:*")] + public async Task ModalDeleteAccountConfirm(string uid, ConfirmDeletionModal modal) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + try + { + if (!string.Equals("DELETE", modal.Delete, StringComparison.Ordinal)) + { + EmbedBuilder eb = new(); + eb.WithTitle("Did not confirm properly"); + eb.WithDescription($"You entered {modal.Delete} but requested was DELETE. Please try again and enter DELETE to confirm."); + eb.WithColor(Color.Red); + ComponentBuilder cb = new(); + cb.WithButton("Cancel", "wizard-delete", emote: new Emoji("❌")); + cb.WithButton("Retry", "wizard-delete-confirm:" + uid, emote: new Emoji("🔁")); + + await ModifyModalInteraction(eb, cb).ConfigureAwait(false); + } + else + { + var maxGroupsByUser = _mareClientConfigurationService.GetValueOrDefault(nameof(ServerConfiguration.MaxGroupUserCount), 3); + + using var db = GetDbContext(); + var user = db.Users.Single(u => u.UID == uid); + await SharedDbFunctions.PurgeUser(_logger, user, db, maxGroupsByUser).ConfigureAwait(false); + + EmbedBuilder eb = new(); + eb.WithTitle($"Account {uid} successfully deleted"); + eb.WithColor(Color.Green); + ComponentBuilder cb = new(); + AddHome(cb); + + await ModifyModalInteraction(eb, cb).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling modal delete account confirm"); + } + } +} diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Recover.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Recover.cs new file mode 100644 index 0000000..95bac24 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Recover.cs @@ -0,0 +1,76 @@ +using Discord.Interactions; +using Discord; +using MareSynchronosShared.Data; +using MareSynchronosShared.Models; +using MareSynchronosShared.Utils; +using Microsoft.EntityFrameworkCore; + +namespace MareSynchronosServices.Discord; + +public partial class MareWizardModule +{ + [ComponentInteraction("wizard-recover")] + public async Task ComponentRecover() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + EmbedBuilder eb = new(); + eb.WithColor(Color.Blue); + eb.WithTitle("Recover"); + eb.WithDescription("In case you have lost your secret key you can recover it here." + Environment.NewLine + Environment.NewLine + + "Use the selection below to select the user account you want to recover." + Environment.NewLine + Environment.NewLine + + "- 1️⃣ is your primary account/UID" + Environment.NewLine + + "- 2️⃣ are all your secondary accounts/UIDs" + Environment.NewLine + + "If you are using Vanity UIDs the original UID is displayed in the second line of the account selection."); + ComponentBuilder cb = new(); + await AddUserSelection(mareDb, cb, "wizard-recover-select").ConfigureAwait(false); + AddHome(cb); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-recover-select")] + public async Task SelectionRecovery(string uid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + EmbedBuilder eb = new(); + eb.WithColor(Color.Green); + await HandleRecovery(mareDb, eb, uid).ConfigureAwait(false); + ComponentBuilder cb = new(); + AddHome(cb); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + private async Task HandleRecovery(MareDbContext db, EmbedBuilder embed, string uid) + { + string computedHash = string.Empty; + Auth auth; + var previousAuth = await db.Auth.Include(u => u.User).FirstOrDefaultAsync(u => u.UserUID == uid).ConfigureAwait(false); + if (previousAuth != null) + { + db.Auth.Remove(previousAuth); + } + + computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); + auth = new Auth() + { + HashedKey = StringUtils.Sha256String(computedHash), + User = previousAuth.User, + PrimaryUserUID = previousAuth.PrimaryUserUID + }; + + await db.Auth.AddAsync(auth).ConfigureAwait(false); + + embed.WithTitle($"Recovery for {uid} complete"); + embed.WithDescription("This is your new private secret key. Do not share this private secret key with anyone. **If you lose it, it is irrevocably lost.**" + + Environment.NewLine + Environment.NewLine + + $"**{computedHash}**" + + Environment.NewLine + Environment.NewLine + + "Enter this key in the Mare Synchronos Service Settings and reconnect to the service."); + + await db.Auth.AddAsync(auth).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + } +} diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Register.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Register.cs new file mode 100644 index 0000000..4582f17 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Register.cs @@ -0,0 +1,267 @@ +using Discord.Interactions; +using Discord; +using MareSynchronosShared.Data; +using Microsoft.EntityFrameworkCore; +using MareSynchronosShared.Utils; +using MareSynchronosShared.Models; + +namespace MareSynchronosServices.Discord; + +public partial class MareWizardModule +{ + [ComponentInteraction("wizard-register")] + public async Task ComponentRegister() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + EmbedBuilder eb = new(); + eb.WithColor(Color.Blue); + eb.WithTitle("Start Registration"); + eb.WithDescription("Here you can start the registration process with the Mare Synchronos server of this Discord." + Environment.NewLine + Environment.NewLine + + "- Have your Lodestone URL ready (i.e. https://eu.finalfantasyxiv.com/lodestone/character/XXXXXXXXX)" + Environment.NewLine + + " - The registration requires you to modify your Lodestone profile with a generated code for verification" + Environment.NewLine + + " - You need to have a paid FFXIV account or someone who can assist you with registration if you can't edit your own Lodestone" + Environment.NewLine + + "- Do not use this on mobile because you will need to be able to copy the generated secret key" + Environment.NewLine); + ComponentBuilder cb = new(); + AddHome(cb); + cb.WithButton("Start Registration", "wizard-register-start", ButtonStyle.Primary, emote: new Emoji("🌒")); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-register-start")] + public async Task ComponentRegisterStart() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var db = GetDbContext(); + var entry = await db.LodeStoneAuth.SingleOrDefaultAsync(u => u.DiscordId == Context.User.Id && u.StartedAt != null).ConfigureAwait(false); + if (entry != null) + { + db.LodeStoneAuth.Remove(entry); + } + _botServices.DiscordLodestoneMapping.TryRemove(Context.User.Id, out _); + _botServices.DiscordVerifiedUsers.TryRemove(Context.User.Id, out _); + + await db.SaveChangesAsync().ConfigureAwait(false); + + await RespondWithModalAsync("wizard-register-lodestone-modal").ConfigureAwait(false); + } + + [ModalInteraction("wizard-register-lodestone-modal")] + public async Task ModalRegister(LodestoneModal lodestoneModal) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + EmbedBuilder eb = new(); + eb.WithColor(Color.Purple); + var success = await HandleRegisterModalAsync(eb, lodestoneModal).ConfigureAwait(false); + ComponentBuilder cb = new(); + cb.WithButton("Cancel", "wizard-register", ButtonStyle.Secondary, emote: new Emoji("❌")); + if (success.Item1) cb.WithButton("Verify", "wizard-register-verify:" + success.Item2, ButtonStyle.Primary, emote: new Emoji("✅")); + else cb.WithButton("Try again", "wizard-register-start", ButtonStyle.Primary, emote: new Emoji("🔁")); + await ModifyModalInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-register-verify:*")] + public async Task ComponentRegisterVerify(string verificationCode) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + _botServices.VerificationQueue.Enqueue(new KeyValuePair>(Context.User.Id, + async (_) => await HandleVerifyAsync(Context.User.Id, verificationCode).ConfigureAwait(false))); + EmbedBuilder eb = new(); + ComponentBuilder cb = new(); + eb.WithColor(Color.Purple); + cb.WithButton("Cancel", "wizard-register", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Check", "wizard-register-verify-check:" + verificationCode, ButtonStyle.Primary, emote: new Emoji("❓")); + eb.WithTitle("Verification Pending"); + eb.WithDescription("Please wait until the bot verifies your registration." + Environment.NewLine + + "Press \"Check\" to check if the verification has been already processed" + Environment.NewLine + Environment.NewLine + + "__This will not advance automatically, you need to press \"Check\".__"); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-register-verify-check:*")] + public async Task ComponentRegisterVerifyCheck(string verificationCode) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + EmbedBuilder eb = new(); + ComponentBuilder cb = new(); + bool stillEnqueued = _botServices.VerificationQueue.Any(k => k.Key == Context.User.Id); + bool verificationRan = _botServices.DiscordVerifiedUsers.TryGetValue(Context.User.Id, out bool verified); + if (!verificationRan) + { + if (stillEnqueued) + { + eb.WithColor(Color.Gold); + eb.WithTitle("Your verification is still pending"); + eb.WithDescription("Please try again and click Check in a few seconds"); + cb.WithButton("Cancel", "wizard-register", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Check", "wizard-register-verify-check:" + verificationCode, ButtonStyle.Primary, emote: new Emoji("❓")); + } + else + { + eb.WithColor(Color.Red); + eb.WithTitle("Something went wrong"); + eb.WithDescription("Your verification was processed but did not arrive properly. Please try to start the registration from the start."); + cb.WithButton("Restart", "wizard-register", ButtonStyle.Primary, emote: new Emoji("🔁")); + } + } + else + { + if (verified) + { + eb.WithColor(Color.Green); + using var db = _services.CreateScope().ServiceProvider.GetRequiredService(); + var (uid, key) = await HandleAddUser(db).ConfigureAwait(false); + eb.WithTitle($"Registration successful, your UID: {uid}"); + eb.WithDescription("This is your private secret key. Do not share this private secret key with anyone. **If you lose it, it is irrevocably lost.**" + + Environment.NewLine + Environment.NewLine + + $"**{key}**" + + Environment.NewLine + Environment.NewLine + + "Enter this key in Mare Synchronos and hit save to connect to the service." + + Environment.NewLine + + "You should connect as soon as possible to not get caught by the automatic cleanup process." + + Environment.NewLine + + "Have fun."); + AddHome(cb); + } + else + { + eb.WithColor(Color.Gold); + eb.WithTitle("Failed to verify registration"); + eb.WithDescription("The bot was not able to find the required verification code on your Lodestone profile." + Environment.NewLine + Environment.NewLine + + "Please restart your verification process, make sure to save your profile _twice_ for it to be properly saved." + Environment.NewLine + Environment.NewLine + + "The code the bot is looking for is" + Environment.NewLine + Environment.NewLine + + "**" + verificationCode + "**"); + cb.WithButton("Cancel", "wizard-register", emote: new Emoji("❌")); + cb.WithButton("Retry", "wizard-register-verify:" + verificationCode, ButtonStyle.Primary, emote: new Emoji("🔁")); + } + } + + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + private async Task<(bool, string)> HandleRegisterModalAsync(EmbedBuilder embed, LodestoneModal arg) + { + var lodestoneId = ParseCharacterIdFromLodestoneUrl(arg.LodestoneUrl); + if (lodestoneId == null) + { + embed.WithTitle("Invalid Lodestone URL"); + embed.WithDescription("The lodestone URL was not valid. It should have following format:" + Environment.NewLine + + "https://eu.finalfantasyxiv.com/lodestone/character/YOUR_LODESTONE_ID/"); + return (false, string.Empty); + } + + // check if userid is already in db + using var scope = _services.CreateScope(); + + var hashedLodestoneId = StringUtils.Sha256String(lodestoneId.ToString()); + + using var db = scope.ServiceProvider.GetService(); + + // check if discord id or lodestone id is banned + if (db.BannedRegistrations.Any(a => a.DiscordIdOrLodestoneAuth == hashedLodestoneId)) + { + embed.WithDescription("This account is banned"); + return (false, string.Empty); + } + + if (db.LodeStoneAuth.Any(a => a.HashedLodestoneId == hashedLodestoneId)) + { + // character already in db + embed.WithDescription("This lodestone character already exists in the Database. If you want to attach this character to your current Discord account use relink."); + return (false, string.Empty); + } + + string lodestoneAuth = await GenerateLodestoneAuth(Context.User.Id, hashedLodestoneId, db).ConfigureAwait(false); + // check if lodestone id is already in db + embed.WithTitle("Authorize your character"); + embed.WithDescription("Add following key to your character profile at https://na.finalfantasyxiv.com/lodestone/my/setting/profile/" + + Environment.NewLine + Environment.NewLine + + $"**{lodestoneAuth}**" + + Environment.NewLine + Environment.NewLine + + $"**! THIS IS NOT THE KEY YOU HAVE TO ENTER IN MARE !**" + + Environment.NewLine + Environment.NewLine + + "Once added and saved, use the button below to Verify and finish registration and receive a secret key to use for Mare Synchronos." + + Environment.NewLine + + "__You can delete the entry from your profile after verification.__" + + Environment.NewLine + Environment.NewLine + + "The verification will expire in approximately 15 minutes. If you fail to verify the registration will be invalidated and you have to register again."); + _botServices.DiscordLodestoneMapping[Context.User.Id] = lodestoneId.ToString(); + + return (true, lodestoneAuth); + } + + private async Task HandleVerifyAsync(ulong userid, string authString) + { + var req = new HttpClient(); + + _botServices.DiscordVerifiedUsers.Remove(userid, out _); + if (_botServices.DiscordLodestoneMapping.ContainsKey(userid)) + { + var randomServer = _botServices.LodestoneServers[random.Next(_botServices.LodestoneServers.Length)]; + var response = await req.GetAsync($"https://{randomServer}.finalfantasyxiv.com/lodestone/character/{_botServices.DiscordLodestoneMapping[userid]}").ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (content.Contains(authString)) + { + _botServices.DiscordVerifiedUsers[userid] = true; + _botServices.DiscordLodestoneMapping.TryRemove(userid, out _); + } + else + { + _botServices.DiscordVerifiedUsers[userid] = false; + } + } + } + } + + private async Task<(string, string)> HandleAddUser(MareDbContext db) + { + var lodestoneAuth = db.LodeStoneAuth.SingleOrDefault(u => u.DiscordId == Context.User.Id); + + var user = new User(); + + var hasValidUid = false; + while (!hasValidUid) + { + var uid = StringUtils.GenerateRandomString(10); + if (db.Users.Any(u => u.UID == uid || u.Alias == uid)) continue; + user.UID = uid; + hasValidUid = true; + } + + // make the first registered user on the service to admin + if (!await db.Users.AnyAsync().ConfigureAwait(false)) + { + user.IsAdmin = true; + } + + user.LastLoggedIn = DateTime.UtcNow; + + var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); + var auth = new Auth() + { + HashedKey = StringUtils.Sha256String(computedHash), + User = user, + }; + + await db.Users.AddAsync(user).ConfigureAwait(false); + await db.Auth.AddAsync(auth).ConfigureAwait(false); + + _botServices.Logger.LogInformation("User registered: {userUID}", user.UID); + + lodestoneAuth.StartedAt = null; + lodestoneAuth.User = user; + lodestoneAuth.LodestoneAuthString = null; + + await db.SaveChangesAsync().ConfigureAwait(false); + + _botServices.DiscordVerifiedUsers.Remove(Context.User.Id, out _); + + return (user.UID, computedHash); + } +} diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Relink.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Relink.cs new file mode 100644 index 0000000..79642b8 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Relink.cs @@ -0,0 +1,250 @@ +using Discord.Interactions; +using Discord; +using MareSynchronosShared.Data; +using MareSynchronosShared.Utils; +using MareSynchronosShared.Models; +using Microsoft.EntityFrameworkCore; + +namespace MareSynchronosServices.Discord; + +public partial class MareWizardModule +{ + [ComponentInteraction("wizard-relink")] + public async Task ComponentRelink() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + EmbedBuilder eb = new(); + eb.WithTitle("Relink"); + eb.WithColor(Color.Blue); + eb.WithDescription("Use this in case you already have a registered Mare account, but lost access to your previous Discord account." + Environment.NewLine + Environment.NewLine + + "- Have your original registered Lodestone URL ready (i.e. https://eu.finalfantasyxiv.com/lodestone/character/XXXXXXXXX)" + Environment.NewLine + + " - The relink process requires you to modify your Lodestone profile with a generated code for verification" + Environment.NewLine + + "- Do not use this on mobile because you will need to be able to copy the generated secret key"); + ComponentBuilder cb = new(); + AddHome(cb); + cb.WithButton("Start Relink", "wizard-relink-start", ButtonStyle.Primary, emote: new Emoji("🔗")); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-relink-start")] + public async Task ComponentRelinkStart() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var db = GetDbContext(); + db.LodeStoneAuth.RemoveRange(db.LodeStoneAuth.Where(u => u.DiscordId == Context.User.Id)); + _botServices.DiscordVerifiedUsers.TryRemove(Context.User.Id, out _); + _botServices.DiscordRelinkLodestoneMapping.TryRemove(Context.User.Id, out _); + await db.SaveChangesAsync().ConfigureAwait(false); + + await RespondWithModalAsync("wizard-relink-lodestone-modal").ConfigureAwait(false); + } + + [ModalInteraction("wizard-relink-lodestone-modal")] + public async Task ModalRelink(LodestoneModal lodestoneModal) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + EmbedBuilder eb = new(); + eb.WithColor(Color.Purple); + var result = await HandleRelinkModalAsync(eb, lodestoneModal).ConfigureAwait(false); + ComponentBuilder cb = new(); + cb.WithButton("Cancel", "wizard-relink", ButtonStyle.Secondary, emote: new Emoji("❌")); + if (result.Success) cb.WithButton("Verify", "wizard-relink-verify:" + result.LodestoneAuth + "," + result.UID, ButtonStyle.Primary, emote: new Emoji("✅")); + else cb.WithButton("Try again", "wizard-relink-start", ButtonStyle.Primary, emote: new Emoji("🔁")); + await ModifyModalInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-relink-verify:*,*")] + public async Task ComponentRelinkVerify(string verificationCode, string uid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + _botServices.VerificationQueue.Enqueue(new KeyValuePair>(Context.User.Id, + async (_) => await HandleVerifyRelinkAsync(Context.User.Id, verificationCode).ConfigureAwait(false))); + EmbedBuilder eb = new(); + ComponentBuilder cb = new(); + eb.WithColor(Color.Purple); + cb.WithButton("Cancel", "wizard-relink", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Check", "wizard-relink-verify-check:" + verificationCode + "," + uid, ButtonStyle.Primary, emote: new Emoji("❓")); + eb.WithTitle("Relink Verification Pending"); + eb.WithDescription("Please wait until the bot verifies your registration." + Environment.NewLine + + "Press \"Check\" to check if the verification has been already processed" + Environment.NewLine + Environment.NewLine + + "__This will not advance automatically, you need to press \"Check\".__"); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-relink-verify-check:*,*")] + public async Task ComponentRelinkVerifyCheck(string verificationCode, string uid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + EmbedBuilder eb = new(); + ComponentBuilder cb = new(); + bool stillEnqueued = _botServices.VerificationQueue.Any(k => k.Key == Context.User.Id); + bool verificationRan = _botServices.DiscordVerifiedUsers.TryGetValue(Context.User.Id, out bool verified); + if (!verificationRan) + { + if (stillEnqueued) + { + eb.WithColor(Color.Gold); + eb.WithTitle("Your relink verification is still pending"); + eb.WithDescription("Please try again and click Check in a few seconds"); + cb.WithButton("Cancel", "wizard-relink", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Check", "wizard-relink-verify-check:" + verificationCode, ButtonStyle.Primary, emote: new Emoji("❓")); + } + else + { + eb.WithColor(Color.Red); + eb.WithTitle("Something went wrong"); + eb.WithDescription("Your relink verification was processed but did not arrive properly. Please try to start the relink process from the start."); + cb.WithButton("Restart", "wizard-relink", ButtonStyle.Primary, emote: new Emoji("🔁")); + } + } + else + { + if (verified) + { + eb.WithColor(Color.Green); + using var db = _services.CreateScope().ServiceProvider.GetRequiredService(); + var (_, key) = await HandleRelinkUser(db, uid).ConfigureAwait(false); + eb.WithTitle($"Relink successful, your UID is again: {uid}"); + eb.WithDescription("This is your private secret key. Do not share this private secret key with anyone. **If you lose it, it is irrevocably lost.**" + + Environment.NewLine + Environment.NewLine + + $"**{key}**" + + Environment.NewLine + Environment.NewLine + + "Enter this key in Mare Synchronos and hit save to connect to the service." + + Environment.NewLine + + "Have fun."); + AddHome(cb); + } + else + { + eb.WithColor(Color.Gold); + eb.WithTitle("Failed to verify relink"); + eb.WithDescription("The bot was not able to find the required verification code on your Lodestone profile." + Environment.NewLine + Environment.NewLine + + "Please restart your relink process, make sure to save your profile _twice_ for it to be properly saved." + Environment.NewLine + Environment.NewLine + + "The code the bot is looking for is" + Environment.NewLine + Environment.NewLine + + "**" + verificationCode + "**"); + cb.WithButton("Cancel", "wizard-relink", emote: new Emoji("❌")); + cb.WithButton("Retry", "wizard-relink-verify:" + verificationCode + "," + uid, ButtonStyle.Primary, emote: new Emoji("🔁")); + } + } + + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + private async Task<(bool Success, string LodestoneAuth, string UID)> HandleRelinkModalAsync(EmbedBuilder embed, LodestoneModal arg) + { + ulong userId = Context.User.Id; + + var lodestoneId = ParseCharacterIdFromLodestoneUrl(arg.LodestoneUrl); + if (lodestoneId == null) + { + embed.WithTitle("Invalid Lodestone URL"); + embed.WithDescription("The lodestone URL was not valid. It should have following format:" + Environment.NewLine + + "https://eu.finalfantasyxiv.com/lodestone/character/YOUR_LODESTONE_ID/"); + return (false, string.Empty, string.Empty); + } + // check if userid is already in db + using var scope = _services.CreateScope(); + + var hashedLodestoneId = StringUtils.Sha256String(lodestoneId.ToString()); + + using var db = scope.ServiceProvider.GetService(); + + // check if discord id or lodestone id is banned + if (db.BannedRegistrations.Any(a => a.DiscordIdOrLodestoneAuth == hashedLodestoneId)) + { + embed.WithTitle("Illegal operation"); + embed.WithDescription("Your account is banned"); + return (false, string.Empty, string.Empty); + } + + if (!db.LodeStoneAuth.Any(a => a.HashedLodestoneId == hashedLodestoneId)) + { + // character already in db + embed.WithTitle("Impossible operation"); + embed.WithDescription("This lodestone character does not exist in the database."); + return (false, string.Empty, string.Empty); + } + + var expectedUser = await db.LodeStoneAuth.Include(u => u.User).SingleAsync(u => u.HashedLodestoneId == hashedLodestoneId).ConfigureAwait(false); + + string lodestoneAuth = await GenerateLodestoneAuth(Context.User.Id, hashedLodestoneId, db).ConfigureAwait(false); + // check if lodestone id is already in db + embed.WithTitle("Authorize your character for relinking"); + embed.WithDescription("Add following key to your character profile at https://na.finalfantasyxiv.com/lodestone/my/setting/profile/" + + Environment.NewLine + Environment.NewLine + + $"**{lodestoneAuth}**" + + Environment.NewLine + Environment.NewLine + + $"**! THIS IS NOT THE KEY YOU HAVE TO ENTER IN MARE !**" + + Environment.NewLine + + "__You can delete the entry from your profile after verification.__" + + Environment.NewLine + Environment.NewLine + + "The verification will expire in approximately 15 minutes. If you fail to verify the relink will be invalidated and you have to relink again."); + _botServices.DiscordRelinkLodestoneMapping[Context.User.Id] = lodestoneId.ToString(); + + return (true, lodestoneAuth, expectedUser.User.UID); + } + + private async Task HandleVerifyRelinkAsync(ulong userid, string authString) + { + var req = new HttpClient(); + + _botServices.DiscordVerifiedUsers.Remove(userid, out _); + if (_botServices.DiscordRelinkLodestoneMapping.ContainsKey(userid)) + { + var randomServer = _botServices.LodestoneServers[random.Next(_botServices.LodestoneServers.Length)]; + var response = await req.GetAsync($"https://{randomServer}.finalfantasyxiv.com/lodestone/character/{_botServices.DiscordRelinkLodestoneMapping[userid]}").ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (content.Contains(authString)) + { + _botServices.DiscordVerifiedUsers[userid] = true; + _botServices.DiscordRelinkLodestoneMapping.TryRemove(userid, out _); + } + else + { + _botServices.DiscordVerifiedUsers[userid] = false; + } + } + } + } + + private async Task<(string, string)> HandleRelinkUser(MareDbContext db, string uid) + { + var oldLodestoneAuth = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid && u.DiscordId != Context.User.Id).ConfigureAwait(false); + var newLodestoneAuth = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == Context.User.Id).ConfigureAwait(false); + + var user = oldLodestoneAuth.User; + + var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); + var auth = new Auth() + { + HashedKey = StringUtils.Sha256String(computedHash), + User = user, + }; + + var previousAuth = await db.Auth.SingleOrDefaultAsync(u => u.UserUID == user.UID).ConfigureAwait(false); + if (previousAuth != null) + { + db.Remove(previousAuth); + } + + newLodestoneAuth.LodestoneAuthString = null; + newLodestoneAuth.StartedAt = null; + newLodestoneAuth.User = user; + db.Update(newLodestoneAuth); + db.Remove(oldLodestoneAuth); + await db.Auth.AddAsync(auth).ConfigureAwait(false); + + _botServices.Logger.LogInformation("User relinked: {userUID}", user.UID); + + await db.SaveChangesAsync().ConfigureAwait(false); + + return (user.UID, computedHash); + } +} diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Secondary.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Secondary.cs new file mode 100644 index 0000000..bb75b8c --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Secondary.cs @@ -0,0 +1,85 @@ +using Discord.Interactions; +using Discord; +using MareSynchronosShared.Data; +using Microsoft.EntityFrameworkCore; +using MareSynchronosShared.Models; +using MareSynchronosShared.Utils; + +namespace MareSynchronosServices.Discord; + +public partial class MareWizardModule +{ + [ComponentInteraction("wizard-secondary")] + public async Task ComponentSecondary() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + var primaryUID = (await mareDb.LodeStoneAuth.Include(u => u.User).SingleAsync(u => u.DiscordId == Context.User.Id).ConfigureAwait(false)).User.UID; + var secondaryUids = await mareDb.Auth.CountAsync(p => p.PrimaryUserUID == primaryUID).ConfigureAwait(false); + EmbedBuilder eb = new(); + eb.WithColor(Color.Blue); + eb.WithTitle("Secondary UID"); + eb.WithDescription("You can create secondary UIDs here. " + Environment.NewLine + Environment.NewLine + + "Secondary UIDs act as completely separate Mare accounts with their own pair list, joined syncshells, UID and so on." + Environment.NewLine + + "Use this to create UIDs if you want to use Mare on two separate game instances at once or keep your alts private." + Environment.NewLine + Environment.NewLine + + "__Note:__ Creating a Secondary UID is _not_ necessary to use Mare for alts." + Environment.NewLine + Environment.NewLine + + $"You currently have {secondaryUids} Secondary UIDs out of a maximum of 25."); + ComponentBuilder cb = new(); + AddHome(cb); + cb.WithButton("Create Secondary UID", "wizard-secondary-create:" + primaryUID, ButtonStyle.Primary, emote: new Emoji("2️⃣"), disabled: secondaryUids >= 25); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-secondary-create:*")] + public async Task ComponentSecondaryCreate(string primaryUid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + EmbedBuilder eb = new(); + eb.WithTitle("Secondary UID created"); + eb.WithColor(Color.Green); + ComponentBuilder cb = new(); + AddHome(cb); + await HandleAddSecondary(mareDb, eb, primaryUid).ConfigureAwait(false); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + public async Task HandleAddSecondary(MareDbContext db, EmbedBuilder embed, string primaryUID) + { + User newUser = new() + { + IsAdmin = false, + IsModerator = false, + LastLoggedIn = DateTime.UtcNow, + }; + + var hasValidUid = false; + while (!hasValidUid) + { + var uid = StringUtils.GenerateRandomString(10); + if (await db.Users.AnyAsync(u => u.UID == uid || u.Alias == uid).ConfigureAwait(false)) continue; + newUser.UID = uid; + hasValidUid = true; + } + + var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); + var auth = new Auth() + { + HashedKey = StringUtils.Sha256String(computedHash), + User = newUser, + PrimaryUserUID = primaryUID + }; + + await db.Users.AddAsync(newUser).ConfigureAwait(false); + await db.Auth.AddAsync(auth).ConfigureAwait(false); + + await db.SaveChangesAsync().ConfigureAwait(false); + + embed.WithDescription("A secondary UID for you was created, use the information below and add the secret key to the Mare setings in the Service Settings tab."); + embed.AddField("UID", newUser.UID); + embed.AddField("Secret Key", computedHash); + } + +} diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.UserInfo.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.UserInfo.cs new file mode 100644 index 0000000..1396596 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.UserInfo.cs @@ -0,0 +1,81 @@ +using Discord.Interactions; +using Discord; +using MareSynchronosShared.Data; +using Microsoft.EntityFrameworkCore; + +namespace MareSynchronosServices.Discord; + +public partial class MareWizardModule +{ + [ComponentInteraction("wizard-userinfo")] + public async Task ComponentUserinfo() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + EmbedBuilder eb = new(); + eb.WithTitle("User Info"); + eb.WithColor(Color.Blue); + eb.WithDescription("You can see information about your user account(s) here." + Environment.NewLine + + "Use the selection below to select a user account to see info for." + Environment.NewLine + Environment.NewLine + + "- 1️⃣ is your primary account/UID" + Environment.NewLine + + "- 2️⃣ are all your secondary accounts/UIDs" + Environment.NewLine + + "If you are using Vanity UIDs the original UID is displayed in the second line of the account selection."); + ComponentBuilder cb = new(); + await AddUserSelection(mareDb, cb, "wizard-userinfo-select").ConfigureAwait(false); + AddHome(cb); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-userinfo-select")] + public async Task SelectionUserinfo(string uid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + EmbedBuilder eb = new(); + eb.WithTitle($"User Info for {uid}"); + await HandleUserInfo(eb, mareDb, uid).ConfigureAwait(false); + eb.WithColor(Color.Green); + ComponentBuilder cb = new(); + await AddUserSelection(mareDb, cb, "wizard-userinfo-select").ConfigureAwait(false); + AddHome(cb); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + private async Task HandleUserInfo(EmbedBuilder eb, MareDbContext db, string uid) + { + ulong userToCheckForDiscordId = Context.User.Id; + + var dbUser = await db.Users.SingleOrDefaultAsync(u => u.UID == uid).ConfigureAwait(false); + + var auth = await db.Auth.Include(u => u.PrimaryUser).SingleOrDefaultAsync(u => u.UserUID == dbUser.UID).ConfigureAwait(false); + var groups = await db.Groups.Where(g => g.OwnerUID == dbUser.UID).ToListAsync().ConfigureAwait(false); + var groupsJoined = await db.GroupPairs.Where(g => g.GroupUserUID == dbUser.UID).ToListAsync().ConfigureAwait(false); + var identity = await _connectionMultiplexer.GetDatabase().StringGetAsync("UID:" + dbUser.UID).ConfigureAwait(false); + + eb.WithDescription("This is the user info for your selected UID. You can check other UIDs or go back using the menu below." + Environment.NewLine + + "If you want to verify your secret key is valid, go to https://emn178.github.io/online-tools/sha256.html and copy your secret key into there and compare it to the Hashed Secret Key provided below."); + if (!string.IsNullOrEmpty(dbUser.Alias)) + { + eb.AddField("Vanity UID", dbUser.Alias); + } + eb.AddField("Last Online (UTC)", dbUser.LastLoggedIn.ToString("U")); + eb.AddField("Currently online ", !string.IsNullOrEmpty(identity)); + eb.AddField("Hashed Secret Key", auth.HashedKey); + eb.AddField("Joined Syncshells", groupsJoined.Count); + eb.AddField("Owned Syncshells", groups.Count); + foreach (var group in groups) + { + var syncShellUserCount = await db.GroupPairs.CountAsync(g => g.GroupGID == group.GID).ConfigureAwait(false); + if (!string.IsNullOrEmpty(group.Alias)) + { + eb.AddField("Owned Syncshell " + group.GID + " Vanity ID", group.Alias); + } + eb.AddField("Owned Syncshell " + group.GID + " User Count", syncShellUserCount); + } + + eb.AddField("Currently online", !string.IsNullOrEmpty(identity)); + } + +} diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Vanity.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Vanity.cs new file mode 100644 index 0000000..b7a864b --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Vanity.cs @@ -0,0 +1,189 @@ +using Discord.Interactions; +using Discord; +using Microsoft.EntityFrameworkCore; +using System.Text.RegularExpressions; +using System.Text; + +namespace MareSynchronosServices.Discord; + +public partial class MareWizardModule +{ + [ComponentInteraction("wizard-vanity")] + public async Task ComponentVanity() + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + StringBuilder sb = new(); + var user = await Context.Guild.GetCurrentUserAsync().ConfigureAwait(false); + bool userIsInVanityRole = _botServices.VanityRoles.Exists(u => user.RoleIds.Contains(u.Id)) || !_botServices.VanityRoles.Any(); + if (!userIsInVanityRole) + { + sb.AppendLine("To be able to set Vanity IDs you must have one of the following roles:"); + foreach (var role in _botServices.VanityRoles) + { + sb.Append("- ").AppendLine(role.Mention); + } + } + else + { + sb.AppendLine("Your current roles on this server allow you to set Vanity IDs."); + } + + EmbedBuilder eb = new(); + eb.WithTitle("Vanity IDs"); + eb.WithDescription("You are able to set your Vanity IDs here." + Environment.NewLine + + "Vanity IDs are a way to customize your displayed UID or Syncshell ID to others." + Environment.NewLine + Environment.NewLine + + sb.ToString()); + eb.WithColor(Color.Blue); + ComponentBuilder cb = new(); + AddHome(cb); + if (userIsInVanityRole) + { + using var db = GetDbContext(); + await AddUserSelection(db, cb, "wizard-vanity-uid").ConfigureAwait(false); + await AddGroupSelection(db, cb, "wizard-vanity-gid").ConfigureAwait(false); + } + + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-vanity-uid")] + public async Task SelectionVanityUid(string uid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + using var db = GetDbContext(); + var user = db.Users.Single(u => u.UID == uid); + EmbedBuilder eb = new(); + eb.WithColor(Color.Purple); + eb.WithTitle($"Set Vanity UID for {uid}"); + eb.WithDescription($"You are about to change the Vanity UID for {uid}" + Environment.NewLine + Environment.NewLine + + "The current Vanity UID is set to: **" + (user.Alias == null ? "No Vanity UID set" : user.Alias) + "**"); + ComponentBuilder cb = new(); + cb.WithButton("Cancel", "wizard-vanity", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Set Vanity ID", "wizard-vanity-uid-set:" + uid, ButtonStyle.Primary, new Emoji("💅")); + + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-vanity-uid-set:*")] + public async Task SelectionVanityUidSet(string uid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + await RespondWithModalAsync("wizard-vanity-uid-modal:" + uid).ConfigureAwait(false); + } + + [ModalInteraction("wizard-vanity-uid-modal:*")] + public async Task ConfirmVanityUidModal(string uid, VanityUidModal modal) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + EmbedBuilder eb = new(); + ComponentBuilder cb = new(); + var desiredVanityUid = modal.DesiredVanityUID; + using var db = GetDbContext(); + bool canAddVanityId = !db.Users.Any(u => u.UID == modal.DesiredVanityUID || u.Alias == modal.DesiredVanityUID); + + Regex rgx = new(@"^[_\-a-zA-Z0-9]{5,15}$", RegexOptions.ECMAScript); + if (!rgx.Match(desiredVanityUid).Success) + { + eb.WithColor(Color.Red); + eb.WithTitle("Invalid Vanity UID"); + eb.WithDescription("A Vanity UID must be between 5 and 15 characters long and only contain the letters A-Z, numbers 0-9, dashes (-) and underscores (_)."); + cb.WithButton("Cancel", "wizard-vanity", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Pick Different UID", "wizard-vanity-uid-set:" + uid, ButtonStyle.Primary, new Emoji("💅")); + } + else if (!canAddVanityId) + { + eb.WithColor(Color.Red); + eb.WithTitle("Vanity UID already taken"); + eb.WithDescription($"The Vanity UID {desiredVanityUid} has already been claimed. Please pick a different one."); + cb.WithButton("Cancel", "wizard-vanity", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Pick Different UID", "wizard-vanity-uid-set:" + uid, ButtonStyle.Primary, new Emoji("💅")); + } + else + { + var user = await db.Users.SingleAsync(u => u.UID == uid).ConfigureAwait(false); + user.Alias = desiredVanityUid; + db.Update(user); + await db.SaveChangesAsync().ConfigureAwait(false); + eb.WithColor(Color.Green); + eb.WithTitle("Vanity UID successfully set"); + eb.WithDescription($"Your Vanity UID for \"{uid}\" was successfully changed to \"{desiredVanityUid}\"." + Environment.NewLine + Environment.NewLine + + "For changes to take effect you need to reconnect to the Mare service."); + AddHome(cb); + } + + await ModifyModalInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-vanity-gid")] + public async Task SelectionVanityGid(string gid) + { + using var db = GetDbContext(); + var group = db.Groups.Single(u => u.GID == gid); + EmbedBuilder eb = new(); + eb.WithColor(Color.Purple); + eb.WithTitle($"Set Vanity GID for {gid}"); + eb.WithDescription($"You are about to change the Vanity Syncshell ID for {gid}" + Environment.NewLine + Environment.NewLine + + "The current Vanity Syncshell ID is set to: **" + (group.Alias == null ? "No Vanity Syncshell ID set" : group.Alias) + "**"); + ComponentBuilder cb = new(); + cb.WithButton("Cancel", "wizard-vanity", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Set Vanity ID", "wizard-vanity-gid-set:" + gid, ButtonStyle.Primary, new Emoji("💅")); + + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + + [ComponentInteraction("wizard-vanity-gid-set:*")] + public async Task SelectionVanityGidSet(string gid) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + await RespondWithModalAsync("wizard-vanity-gid-modal:" + gid).ConfigureAwait(false); + } + + [ModalInteraction("wizard-vanity-gid-modal:*")] + public async Task ConfirmVanityGidModal(string gid, VanityGidModal modal) + { + if (!(await ValidateInteraction().ConfigureAwait(false))) return; + + EmbedBuilder eb = new(); + ComponentBuilder cb = new(); + var desiredVanityUid = modal.DesiredVanityGID; + using var db = GetDbContext(); + bool canAddVanityId = !db.Groups.Any(u => u.GID == modal.DesiredVanityGID || u.Alias == modal.DesiredVanityGID); + + Regex rgx = new(@"^[_\-a-zA-Z0-9]{5,15}$", RegexOptions.ECMAScript); + if (!rgx.Match(desiredVanityUid).Success) + { + eb.WithColor(Color.Red); + eb.WithTitle("Invalid Vanity Syncshell ID"); + eb.WithDescription("A Vanity Syncshell ID must be between 5 and 20 characters long and only contain the letters A-Z, numbers 0-9, dashes (-) and underscores (_)."); + cb.WithButton("Cancel", "wizard-vanity", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Pick Different ID", "wizard-vanity-gid-set:" + gid, ButtonStyle.Primary, new Emoji("💅")); + } + else if (!canAddVanityId) + { + eb.WithColor(Color.Red); + eb.WithTitle("Vanity Syncshell ID already taken"); + eb.WithDescription($"The Vanity Synshell ID \"{desiredVanityUid}\" has already been claimed. Please pick a different one."); + cb.WithButton("Cancel", "wizard-vanity", ButtonStyle.Secondary, emote: new Emoji("❌")); + cb.WithButton("Pick Different ID", "wizard-vanity-gid-set:" + gid, ButtonStyle.Primary, new Emoji("💅")); + } + else + { + var group = await db.Groups.SingleAsync(u => u.GID == gid).ConfigureAwait(false); + group.Alias = desiredVanityUid; + db.Update(group); + await db.SaveChangesAsync().ConfigureAwait(false); + eb.WithColor(Color.Green); + eb.WithTitle("Vanity Syncshell ID successfully set"); + eb.WithDescription($"Your Vanity Syncshell ID for {gid} was successfully changed to \"{desiredVanityUid}\"." + Environment.NewLine + Environment.NewLine + + "For changes to take effect you need to reconnect to the Mare service."); + AddHome(cb); + } + + await ModifyModalInteraction(eb, cb).ConfigureAwait(false); + } +} diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.cs new file mode 100644 index 0000000..024db99 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.cs @@ -0,0 +1,252 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using MareSynchronosShared.Data; +using MareSynchronosShared.Models; +using MareSynchronosShared.Services; +using MareSynchronosShared.Utils; +using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; +using System.Text.RegularExpressions; + +namespace MareSynchronosServices.Discord; + +public partial class MareWizardModule : InteractionModuleBase +{ + private ILogger _logger; + private IServiceProvider _services; + private DiscordBotServices _botServices; + private IConfigurationService _mareClientConfigurationService; + private IConfigurationService _mareServicesConfiguration; + private IConnectionMultiplexer _connectionMultiplexer; + private Random random = new(); + + public MareWizardModule(ILogger logger, IServiceProvider services, DiscordBotServices botServices, + IConfigurationService mareClientConfigurationService, + IConfigurationService mareServicesConfiguration, + IConnectionMultiplexer connectionMultiplexer) + { + _logger = logger; + _services = services; + _botServices = botServices; + _mareClientConfigurationService = mareClientConfigurationService; + _mareServicesConfiguration = mareServicesConfiguration; + _connectionMultiplexer = connectionMultiplexer; + } + + + [ComponentInteraction("wizard-home:*")] + public async Task StartWizard(bool init = false) + { + if (!init && !(await ValidateInteraction().ConfigureAwait(false))) return; + + using var mareDb = GetDbContext(); + bool hasAccount = await mareDb.LodeStoneAuth.AnyAsync(u => u.DiscordId == Context.User.Id && u.StartedAt == null).ConfigureAwait(false); + + if (init) + { + bool isBanned = await mareDb.BannedRegistrations.AnyAsync(u => u.DiscordIdOrLodestoneAuth == Context.User.Id.ToString()).ConfigureAwait(false); + + if (isBanned) + { + EmbedBuilder ebBanned = new(); + ebBanned.WithTitle("You are not welcome here"); + ebBanned.WithDescription("Your Discord account is banned"); + await RespondAsync(embed: ebBanned.Build(), ephemeral: true).ConfigureAwait(false); + return; + } + } + + EmbedBuilder eb = new(); + eb.WithTitle("Welcome to the Mare Synchronos Service Bot for this server"); + eb.WithDescription("Here is what you can do:" + Environment.NewLine + Environment.NewLine + + (!hasAccount ? string.Empty : ("- Check your account status press \"ℹ️ User Info\"" + Environment.NewLine)) + + (hasAccount ? string.Empty : ("- Register a new Mare Account press \"🌒 Register\"" + Environment.NewLine)) + + (!hasAccount ? string.Empty : ("- You lost your secret key press \"🏥 Recover\"" + Environment.NewLine)) + + (hasAccount ? string.Empty : ("- If you have changed your Discord account press \"🔗 Relink\"" + Environment.NewLine)) + + (!hasAccount ? string.Empty : ("- Create a secondary UIDs press \"2️⃣ Secondary UID\"" + Environment.NewLine)) + + (!hasAccount ? string.Empty : ("- Set a Vanity UID press \"💅 Vanity IDs\"" + Environment.NewLine)) + + (!hasAccount ? string.Empty : ("- Delete your primary or secondary accounts with \"⚠️ Delete\"")) + ); + eb.WithColor(Color.Blue); + ComponentBuilder cb = new(); + if (!hasAccount) + { + cb.WithButton("Register", "wizard-register", ButtonStyle.Primary, new Emoji("🌒")); + cb.WithButton("Relink", "wizard-relink", ButtonStyle.Secondary, new Emoji("🔗")); + } + else + { + cb.WithButton("User Info", "wizard-userinfo", ButtonStyle.Secondary, new Emoji("ℹ️")); + cb.WithButton("Recover", "wizard-recover", ButtonStyle.Secondary, new Emoji("🏥")); + cb.WithButton("Secondary UID", "wizard-secondary", ButtonStyle.Secondary, new Emoji("2️⃣")); + cb.WithButton("Vanity IDs", "wizard-vanity", ButtonStyle.Secondary, new Emoji("💅")); + cb.WithButton("Delete", "wizard-delete", ButtonStyle.Danger, new Emoji("⚠️")); + } + if (init) + { + await RespondAsync(embed: eb.Build(), components: cb.Build(), ephemeral: true).ConfigureAwait(false); + var resp = await GetOriginalResponseAsync().ConfigureAwait(false); + _botServices.ValidInteractions[Context.User.Id] = resp.Id; + _logger.LogInformation("Init Msg: {id}", resp.Id); + } + else + { + await ModifyInteraction(eb, cb).ConfigureAwait(false); + } + } + + public class VanityUidModal : IModal + { + public string Title => "Set Vanity UID"; + + [InputLabel("Set your Vanity UID")] + [ModalTextInput("vanity_uid", TextInputStyle.Short, "5-15 characters, underscore, dash", 5, 15)] + public string DesiredVanityUID { get; set; } + } + + public class VanityGidModal : IModal + { + public string Title => "Set Vanity Syncshell ID"; + + [InputLabel("Set your Vanity Syncshell ID")] + [ModalTextInput("vanity_gid", TextInputStyle.Short, "5-20 characters, underscore, dash", 5, 20)] + public string DesiredVanityGID { get; set; } + } + + public class ConfirmDeletionModal : IModal + { + public string Title => "Confirm Account Deletion"; + + [InputLabel("Enter \"DELETE\" in all Caps")] + [ModalTextInput("confirmation", TextInputStyle.Short, "Enter DELETE")] + public string Delete { get; set; } + } + + private MareDbContext GetDbContext() + { + return _services.CreateScope().ServiceProvider.GetService(); + } + + private async Task ValidateInteraction() + { + if (Context.Interaction is not IComponentInteraction componentInteraction) return true; + + if (_botServices.ValidInteractions.TryGetValue(Context.User.Id, out ulong interactionId) && interactionId == componentInteraction.Message.Id) + { + return true; + } + + EmbedBuilder eb = new(); + eb.WithTitle("Session expired"); + eb.WithDescription("This session has expired since you have either again pressed \"Start\" on the initial message or the bot has been restarted." + Environment.NewLine + Environment.NewLine + + "Please use the newly started interaction or start a new one."); + eb.WithColor(Color.Red); + ComponentBuilder cb = new(); + await ModifyInteraction(eb, cb).ConfigureAwait(false); + + return false; + } + + private void AddHome(ComponentBuilder cb) + { + cb.WithButton("Return to Home", "wizard-home:false", ButtonStyle.Secondary, new Emoji("🏠")); + } + + private async Task ModifyModalInteraction(EmbedBuilder eb, ComponentBuilder cb) + { + await (Context.Interaction as SocketModal).UpdateAsync(m => + { + m.Embed = eb.Build(); + m.Components = cb.Build(); + }).ConfigureAwait(false); + } + + private async Task ModifyInteraction(EmbedBuilder eb, ComponentBuilder cb) + { + await ((Context.Interaction) as IComponentInteraction).UpdateAsync(m => + { + m.Embed = eb.Build(); + m.Components = cb.Build(); + }).ConfigureAwait(false); + } + + private async Task AddUserSelection(MareDbContext mareDb, ComponentBuilder cb, string customId) + { + var discordId = Context.User.Id; + var existingAuth = await mareDb.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(e => e.DiscordId == discordId).ConfigureAwait(false); + if (existingAuth != null) + { + SelectMenuBuilder sb = new(); + sb.WithPlaceholder("Select a UID"); + sb.WithCustomId(customId); + var existingUids = await mareDb.Auth.Include(u => u.User).Where(u => u.UserUID == existingAuth.User.UID || u.PrimaryUserUID == existingAuth.User.UID) + .OrderByDescending(u => u.PrimaryUser == null).ToListAsync().ConfigureAwait(false); + foreach (var entry in existingUids) + { + sb.AddOption(string.IsNullOrEmpty(entry.User.Alias) ? entry.UserUID : entry.User.Alias, + entry.UserUID, + !string.IsNullOrEmpty(entry.User.Alias) ? entry.User.UID : null, + entry.PrimaryUserUID == null ? new Emoji("1️⃣") : new Emoji("2️⃣")); + } + cb.WithSelectMenu(sb); + } + } + + private async Task AddGroupSelection(MareDbContext db, ComponentBuilder cb, string customId) + { + var primary = (await db.LodeStoneAuth.Include(u => u.User).SingleAsync(u => u.DiscordId == Context.User.Id).ConfigureAwait(false)).User; + var secondary = await db.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == primary.UID).Select(u => u.User).ToListAsync().ConfigureAwait(false); + var primaryGids = (await db.Groups.Include(u => u.Owner).Where(u => u.OwnerUID == primary.UID).ToListAsync().ConfigureAwait(false)); + var secondaryGids = (await db.Groups.Include(u => u.Owner).Where(u => secondary.Select(u => u.UID).Contains(u.OwnerUID)).ToListAsync().ConfigureAwait(false)); + SelectMenuBuilder gids = new(); + if (primaryGids.Any() || secondaryGids.Any()) + { + foreach (var item in primaryGids) + { + gids.AddOption(item.Alias ?? item.GID, item.GID, (item.Alias == null ? string.Empty : item.GID) + $" ({item.Owner.Alias ?? item.Owner.UID})", new Emoji("1️⃣")); + } + foreach (var item in secondaryGids) + { + gids.AddOption(item.Alias ?? item.GID, item.GID, (item.Alias == null ? string.Empty : item.GID) + $" ({item.Owner.Alias ?? item.Owner.UID})", new Emoji("2️⃣")); + } + gids.WithCustomId(customId); + gids.WithPlaceholder("Select a Syncshell"); + cb.WithSelectMenu(gids); + } + } + + private async Task GenerateLodestoneAuth(ulong discordid, string hashedLodestoneId, MareDbContext dbContext) + { + var auth = StringUtils.GenerateRandomString(32); + LodeStoneAuth lsAuth = new LodeStoneAuth() + { + DiscordId = discordid, + HashedLodestoneId = hashedLodestoneId, + LodestoneAuthString = auth, + StartedAt = DateTime.UtcNow + }; + + dbContext.Add(lsAuth); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + + return (auth); + } + + private int? ParseCharacterIdFromLodestoneUrl(string lodestoneUrl) + { + var regex = new Regex(@"https:\/\/(na|eu|de|fr|jp)\.finalfantasyxiv\.com\/lodestone\/character\/\d+"); + var matches = regex.Match(lodestoneUrl); + var isLodestoneUrl = matches.Success; + if (!isLodestoneUrl || matches.Groups.Count < 1) return null; + + lodestoneUrl = matches.Groups[0].ToString(); + var stringId = lodestoneUrl.Split('/', StringSplitOptions.RemoveEmptyEntries).Last(); + if (!int.TryParse(stringId, out int lodestoneId)) + { + return null; + } + + return lodestoneId; + } +} diff --git a/MareSynchronosServer/MareSynchronosShared/Utils/ServicesConfiguration.cs b/MareSynchronosServer/MareSynchronosShared/Utils/ServicesConfiguration.cs index 8481c46..72d0aac 100644 --- a/MareSynchronosServer/MareSynchronosShared/Utils/ServicesConfiguration.cs +++ b/MareSynchronosServer/MareSynchronosShared/Utils/ServicesConfiguration.cs @@ -7,7 +7,9 @@ public class ServicesConfiguration : MareConfigurationBase public string DiscordBotToken { get; set; } = string.Empty; public ulong? DiscordChannelForMessages { get; set; } = null; public ulong? DiscordChannelForReports { get; set; } = null; + public ulong? DiscordChannelForCommands { get; set; } = null; public Uri MainServerGrpcAddress { get; set; } = null; + public ulong[]? VanityRoles { get; set; } = null; public override string ToString() { @@ -17,6 +19,11 @@ public class ServicesConfiguration : MareConfigurationBase sb.AppendLine($"{nameof(MainServerGrpcAddress)} => {MainServerGrpcAddress}"); sb.AppendLine($"{nameof(DiscordChannelForMessages)} => {DiscordChannelForMessages}"); sb.AppendLine($"{nameof(DiscordChannelForReports)} => {DiscordChannelForReports}"); + sb.AppendLine($"{nameof(DiscordChannelForCommands)} => {DiscordChannelForCommands}"); + foreach (var role in VanityRoles) + { + sb.AppendLine($"{nameof(VanityRoles)} => {role}"); + } return sb.ToString(); } } \ No newline at end of file