rebuild discord bot to better user interactions

This commit is contained in:
rootdarkarchon
2023-09-17 00:05:57 +02:00
parent 56b27e5ee8
commit 7e90187822
13 changed files with 1409 additions and 7 deletions

View File

@@ -51,6 +51,7 @@ services:
MareSynchronos__DiscordBotToken: "${DEV_MARE_DISCORDTOKEN}" MareSynchronos__DiscordBotToken: "${DEV_MARE_DISCORDTOKEN}"
MareSynchronos__DiscordChannelForMessages: "${DEV_MARE_DISCORDCHANNEL}" MareSynchronos__DiscordChannelForMessages: "${DEV_MARE_DISCORDCHANNEL}"
MareSynchronos__DiscordChannelForReports: "${DEV_MARE_DISCORDCHANNEL}" MareSynchronos__DiscordChannelForReports: "${DEV_MARE_DISCORDCHANNEL}"
MareSynchronos__DiscordChannelForCommands: "${DEV_MARE_DISCORDCHANNEL}"
DOTNET_USE_POLLING_FILE_WATCHER: 1 DOTNET_USE_POLLING_FILE_WATCHER: 1
volumes: volumes:
- ../config/standalone/services-standalone.json:/opt/MareSynchronosServices/appsettings.json - ../config/standalone/services-standalone.json:/opt/MareSynchronosServices/appsettings.json

View File

@@ -33,8 +33,10 @@
"MainServerGrpcAddress": "http://mare-server:6005/", "MainServerGrpcAddress": "http://mare-server:6005/",
"DiscordBotToken": "", "DiscordBotToken": "",
"DiscordChannelForMessages": "", "DiscordChannelForMessages": "",
"DiscordChannelForCommands": "",
"Jwt": "teststringteststringteststringteststringteststringteststringteststringteststringteststringteststring", "Jwt": "teststringteststringteststringteststringteststringteststringteststringteststringteststringteststring",
"RedisConnectionString": "redis,password=secretredispassword" "RedisConnectionString": "redis,password=secretredispassword",
"VanityRoles": []
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Kestrel": { "Kestrel": {

View File

@@ -13,6 +13,7 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using StackExchange.Redis; using StackExchange.Redis;
using System.Text; using System.Text;
using System.Threading.Channels;
namespace MareSynchronosServices.Discord; namespace MareSynchronosServices.Discord;
@@ -54,7 +55,9 @@ internal class DiscordBot : IHostedService
if (!string.IsNullOrEmpty(token)) if (!string.IsNullOrEmpty(token))
{ {
_interactionModule = new InteractionService(_discordClient); _interactionModule = new InteractionService(_discordClient);
_interactionModule.Log += Log;
await _interactionModule.AddModuleAsync(typeof(MareModule), _services).ConfigureAwait(false); 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.LoginAsync(TokenType.Bot, token).ConfigureAwait(false);
await _discordClient.StartAsync().ConfigureAwait(false); await _discordClient.StartAsync().ConfigureAwait(false);
@@ -64,10 +67,10 @@ internal class DiscordBot : IHostedService
_discordClient.InteractionCreated += async (x) => _discordClient.InteractionCreated += async (x) =>
{ {
var ctx = new SocketInteractionContext(_discordClient, 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(); _ = UpdateStatusAsync();
} }
} }
@@ -78,7 +81,7 @@ internal class DiscordBot : IHostedService
{ {
_discordClient.ButtonExecuted -= ButtonExecutedHandler; _discordClient.ButtonExecuted -= ButtonExecutedHandler;
await _botServices.Stop(); await _botServices.Stop().ConfigureAwait(false);
_processReportQueueCts?.Cancel(); _processReportQueueCts?.Cancel();
_updateStatusCts?.Cancel(); _updateStatusCts?.Cancel();
_vanityUpdateCts?.Cancel(); _vanityUpdateCts?.Cancel();
@@ -197,16 +200,100 @@ internal class DiscordBot : IHostedService
private async Task DiscordClient_Ready() 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 _interactionModule.RegisterCommandsToGuildAsync(guild.Id, true).ConfigureAwait(false);
await CreateOrUpdateModal(guild).ConfigureAwait(false);
_ = RemoveUsersNotInVanityRole(); _ = RemoveUsersNotInVanityRole();
_ = ProcessReportsQueue(); _ = ProcessReportsQueue();
_ = UpdateVanityRoles(guild);
}
private async Task UpdateVanityRoles(RestGuild guild)
{
while (!_updateStatusCts.IsCancellationRequested)
{
var vanityRoles = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.VanityRoles), Array.Empty<ulong>());
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<ulong?>(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) 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; return Task.CompletedTask;
} }

View File

@@ -1,4 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Discord.Rest;
using Discord.WebSocket;
using MareSynchronosShared.Metrics; using MareSynchronosShared.Metrics;
namespace MareSynchronosServices.Discord; namespace MareSynchronosServices.Discord;
@@ -8,10 +10,13 @@ public class DiscordBotServices
public readonly string[] LodestoneServers = new[] { "eu", "na", "jp", "fr", "de" }; public readonly string[] LodestoneServers = new[] { "eu", "na", "jp", "fr", "de" };
public ConcurrentDictionary<ulong, string> DiscordLodestoneMapping = new(); public ConcurrentDictionary<ulong, string> DiscordLodestoneMapping = new();
public ConcurrentDictionary<ulong, string> DiscordRelinkLodestoneMapping = new(); public ConcurrentDictionary<ulong, string> DiscordRelinkLodestoneMapping = new();
public ConcurrentDictionary<ulong, bool> DiscordVerifiedUsers { get; } = new();
public ConcurrentDictionary<ulong, DateTime> LastVanityChange = new(); public ConcurrentDictionary<ulong, DateTime> LastVanityChange = new();
public ConcurrentDictionary<string, DateTime> LastVanityGidChange = new(); public ConcurrentDictionary<string, DateTime> LastVanityGidChange = new();
public ConcurrentDictionary<ulong, ulong> ValidInteractions { get; } = new();
public List<RestRole> VanityRoles { get; set; } = new();
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private CancellationTokenSource? verificationTaskCts; private CancellationTokenSource verificationTaskCts;
public DiscordBotServices(IServiceProvider serviceProvider, ILogger<DiscordBotServices> logger, MareMetrics metrics) public DiscordBotServices(IServiceProvider serviceProvider, ILogger<DiscordBotServices> logger, MareMetrics metrics)
{ {

View File

@@ -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<ConfirmDeletionModal>("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");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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<LodestoneModal>("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<ulong, Action<IServiceProvider>>(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<MareDbContext>();
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<MareDbContext>();
// 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);
}
}

View File

@@ -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<LodestoneModal>("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<ulong, Action<IServiceProvider>>(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<MareDbContext>();
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<MareDbContext>();
// 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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<VanityUidModal>("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<VanityGidModal>("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);
}
}

View File

@@ -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<MareModule> _logger;
private IServiceProvider _services;
private DiscordBotServices _botServices;
private IConfigurationService<ServerConfiguration> _mareClientConfigurationService;
private IConfigurationService<ServicesConfiguration> _mareServicesConfiguration;
private IConnectionMultiplexer _connectionMultiplexer;
private Random random = new();
public MareWizardModule(ILogger<MareModule> logger, IServiceProvider services, DiscordBotServices botServices,
IConfigurationService<ServerConfiguration> mareClientConfigurationService,
IConfigurationService<ServicesConfiguration> 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<MareDbContext>();
}
private async Task<bool> 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<string> 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;
}
}

View File

@@ -7,7 +7,9 @@ public class ServicesConfiguration : MareConfigurationBase
public string DiscordBotToken { get; set; } = string.Empty; public string DiscordBotToken { get; set; } = string.Empty;
public ulong? DiscordChannelForMessages { get; set; } = null; public ulong? DiscordChannelForMessages { get; set; } = null;
public ulong? DiscordChannelForReports { get; set; } = null; public ulong? DiscordChannelForReports { get; set; } = null;
public ulong? DiscordChannelForCommands { get; set; } = null;
public Uri MainServerGrpcAddress { get; set; } = null; public Uri MainServerGrpcAddress { get; set; } = null;
public ulong[]? VanityRoles { get; set; } = null;
public override string ToString() public override string ToString()
{ {
@@ -17,6 +19,11 @@ public class ServicesConfiguration : MareConfigurationBase
sb.AppendLine($"{nameof(MainServerGrpcAddress)} => {MainServerGrpcAddress}"); sb.AppendLine($"{nameof(MainServerGrpcAddress)} => {MainServerGrpcAddress}");
sb.AppendLine($"{nameof(DiscordChannelForMessages)} => {DiscordChannelForMessages}"); sb.AppendLine($"{nameof(DiscordChannelForMessages)} => {DiscordChannelForMessages}");
sb.AppendLine($"{nameof(DiscordChannelForReports)} => {DiscordChannelForReports}"); sb.AppendLine($"{nameof(DiscordChannelForReports)} => {DiscordChannelForReports}");
sb.AppendLine($"{nameof(DiscordChannelForCommands)} => {DiscordChannelForCommands}");
foreach (var role in VanityRoles)
{
sb.AppendLine($"{nameof(VanityRoles)} => {role}");
}
return sb.ToString(); return sb.ToString();
} }
} }