This commit is contained in:
Zurazan
2025-08-27 03:02:29 +02:00
commit 80235a174b
344 changed files with 43249 additions and 0 deletions

View File

@@ -0,0 +1,434 @@
using Discord;
using Discord.Interactions;
using Discord.Rest;
using Discord.WebSocket;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using StackExchange.Redis;
namespace LightlessSyncServices.Discord;
internal class DiscordBot : IHostedService
{
private readonly DiscordBotServices _botServices;
private readonly IConfigurationService<ServicesConfiguration> _configurationService;
private readonly IConnectionMultiplexer _connectionMultiplexer;
private readonly DiscordSocketClient _discordClient;
private readonly ILogger<DiscordBot> _logger;
private readonly IDbContextFactory<LightlessDbContext> _dbContextFactory;
private readonly IServiceProvider _services;
private InteractionService _interactionModule;
private readonly CancellationTokenSource? _processReportQueueCts;
private CancellationTokenSource? _clientConnectedCts;
public DiscordBot(DiscordBotServices botServices, IServiceProvider services, IConfigurationService<ServicesConfiguration> configuration,
IDbContextFactory<LightlessDbContext> dbContextFactory,
ILogger<DiscordBot> logger, IConnectionMultiplexer connectionMultiplexer)
{
_botServices = botServices;
_services = services;
_configurationService = configuration;
_dbContextFactory = dbContextFactory;
_logger = logger;
_connectionMultiplexer = connectionMultiplexer;
_discordClient = new(new DiscordSocketConfig()
{
DefaultRetryMode = RetryMode.AlwaysRetry,
GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.GuildMembers
});
_discordClient.Log += Log;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var token = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty);
if (!string.IsNullOrEmpty(token))
{
_logger.LogInformation("Starting DiscordBot");
_logger.LogInformation("Using Configuration: " + _configurationService.ToString());
_interactionModule?.Dispose();
_interactionModule = new InteractionService(_discordClient);
_interactionModule.Log += Log;
await _interactionModule.AddModuleAsync(typeof(LightlessModule), _services).ConfigureAwait(false);
await _interactionModule.AddModuleAsync(typeof(LightlessWizardModule), _services).ConfigureAwait(false);
await _discordClient.LoginAsync(TokenType.Bot, token).ConfigureAwait(false);
await _discordClient.StartAsync().ConfigureAwait(false);
_discordClient.Ready += DiscordClient_Ready;
_discordClient.InteractionCreated += async (x) =>
{
var ctx = new SocketInteractionContext(_discordClient, x);
await _interactionModule.ExecuteCommandAsync(ctx, _services).ConfigureAwait(false);
};
_discordClient.UserJoined += OnUserJoined;
await _botServices.Start().ConfigureAwait(false);
}
}
private async Task OnUserJoined(SocketGuildUser arg)
{
try
{
using LightlessDbContext dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
var alreadyRegistered = await dbContext.LodeStoneAuth.AnyAsync(u => u.DiscordId == arg.Id).ConfigureAwait(false);
if (alreadyRegistered)
{
await _botServices.AddRegisteredRoleAsync(arg).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set user role on join");
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(_configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty)))
{
await _botServices.Stop().ConfigureAwait(false);
_processReportQueueCts?.Cancel();
_clientConnectedCts?.Cancel();
await _discordClient.LogoutAsync().ConfigureAwait(false);
await _discordClient.StopAsync().ConfigureAwait(false);
_interactionModule?.Dispose();
}
}
private async Task DiscordClient_Ready()
{
var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First();
await _interactionModule.RegisterCommandsToGuildAsync(guild.Id, true).ConfigureAwait(false);
_clientConnectedCts?.Cancel();
_clientConnectedCts?.Dispose();
_clientConnectedCts = new();
_ = UpdateStatusAsync(_clientConnectedCts.Token);
await CreateOrUpdateModal(guild).ConfigureAwait(false);
_botServices.UpdateGuild(guild);
await _botServices.LogToChannel("Bot startup complete.").ConfigureAwait(false);
_ = UpdateVanityRoles(guild, _clientConnectedCts.Token);
_ = RemoveUsersNotInVanityRole(_clientConnectedCts.Token);
_ = RemoveUnregisteredUsers(_clientConnectedCts.Token);
}
private async Task UpdateVanityRoles(RestGuild guild, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
_logger.LogInformation("Updating Vanity Roles");
Dictionary<ulong, string> vanityRoles = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.VanityRoles), new Dictionary<ulong, string>());
if (vanityRoles.Keys.Count != _botServices.VanityRoles.Count)
{
_botServices.VanityRoles.Clear();
foreach (var role in vanityRoles)
{
_logger.LogInformation("Adding Role: {id} => {desc}", role.Key, role.Value);
var restrole = guild.GetRole(role.Key);
if (restrole != null)
_botServices.VanityRoles[restrole] = role.Value;
}
}
await Task.Delay(TimeSpan.FromSeconds(30), token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during UpdateVanityRoles");
}
}
}
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("Lightless Services Bot Interaction Service");
eb.WithDescription("Press \"Start\" to interact with this bot!" + Environment.NewLine + Environment.NewLine
+ "You can handle all of your Lightless account needs in this server through the easy to use interactive bot prompt. Just follow the instructions!");
eb.WithThumbnailUrl("https://raw.githubusercontent.com/Light-Public-Syncshells/LightlessSync/refs/heads/main/LightlessSync/icon.png");
var cb = new ComponentBuilder();
cb.WithButton("Start", style: ButtonStyle.Primary, customId: "wizard-captcha:true", emote: Emoji.Parse("➡️"));
if (prevMessage == null)
{
var msg = await channel.SendMessageAsync(embed: eb.Build(), components: cb.Build()).ConfigureAwait(false);
try
{
await msg.PinAsync().ConfigureAwait(false);
}
catch (Exception)
{
// swallow
}
}
else
{
await prevMessage.ModifyAsync(p =>
{
p.Embed = eb.Build();
p.Components = cb.Build();
}).ConfigureAwait(false);
}
}
private Task Log(LogMessage 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;
}
private async Task RemoveUnregisteredUsers(CancellationToken token)
{
var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First();
while (!token.IsCancellationRequested)
{
try
{
await ProcessUserRoles(guild, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// do nothing
}
catch (Exception ex)
{
await _botServices.LogToChannel($"Error during user procesing: {ex.Message}").ConfigureAwait(false);
}
await Task.Delay(TimeSpan.FromDays(1)).ConfigureAwait(false);
}
}
private async Task ProcessUserRoles(RestGuild guild, CancellationToken token)
{
using LightlessDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(token).ConfigureAwait(false);
var roleId = _configurationService.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordRoleRegistered), 0);
var kickUnregistered = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.KickNonRegisteredUsers), false);
if (roleId == null) return;
var registrationRole = guild.Roles.FirstOrDefault(f => f.Id == roleId.Value);
var registeredUsers = new HashSet<ulong>(await dbContext.LodeStoneAuth.AsNoTracking().Select(c => c.DiscordId).ToListAsync().ConfigureAwait(false));
var executionStartTime = DateTimeOffset.UtcNow;
int processedUsers = 0;
int addedRoles = 0;
int kickedUsers = 0;
int totalRoles = 0;
int toRemoveUsers = 0;
int freshUsers = 0;
await _botServices.LogToChannel($"Starting to process registered users: Adding Role {registrationRole.Name}. Kick Stale Unregistered: {kickUnregistered}.").ConfigureAwait(false);
await foreach (var userList in guild.GetUsersAsync(new RequestOptions { CancelToken = token }).ConfigureAwait(false))
{
_logger.LogInformation("Processing chunk of {count} users, total processed: {proc}, total roles: {total}, roles added: {added}, users kicked: {kicked}, users plan to kick: {planToKick}, fresh user: {fresh}",
userList.Count, processedUsers, totalRoles + addedRoles, addedRoles, kickedUsers, toRemoveUsers, freshUsers);
foreach (var user in userList)
{
if (user.IsBot) continue;
if (registeredUsers.Contains(user.Id))
{
bool roleAdded = await _botServices.AddRegisteredRoleAsync(user, registrationRole).ConfigureAwait(false);
if (roleAdded) addedRoles++;
else totalRoles++;
}
else
{
if ((executionStartTime - user.JoinedAt.Value).TotalDays > 7)
{
if (kickUnregistered)
{
await _botServices.KickUserAsync(user).ConfigureAwait(false);
kickedUsers++;
}
else
{
toRemoveUsers++;
}
}
else
{
freshUsers++;
}
}
token.ThrowIfCancellationRequested();
processedUsers++;
}
}
await _botServices.LogToChannel($"Processing registered users finished. Processed {processedUsers} users, added {addedRoles} roles and kicked {kickedUsers} users").ConfigureAwait(false);
}
private async Task RemoveUsersNotInVanityRole(CancellationToken token)
{
var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First();
while (!token.IsCancellationRequested)
{
try
{
_logger.LogInformation($"Cleaning up Vanity UIDs");
await _botServices.LogToChannel("Cleaning up Vanity UIDs").ConfigureAwait(false);
_logger.LogInformation("Getting rest guild {guildName}", guild.Name);
var restGuild = await _discordClient.Rest.GetGuildAsync(guild.Id).ConfigureAwait(false);
Dictionary<ulong, string> allowedRoleIds = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.VanityRoles), new Dictionary<ulong, string>());
_logger.LogInformation($"Allowed role ids: {string.Join(", ", allowedRoleIds)}");
if (allowedRoleIds.Any())
{
using var db = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
var aliasedUsers = await db.LodeStoneAuth.Include("User")
.Where(c => c.User != null && !string.IsNullOrEmpty(c.User.Alias)).ToListAsync().ConfigureAwait(false);
var aliasedGroups = await db.Groups.Include(u => u.Owner)
.Where(c => !string.IsNullOrEmpty(c.Alias)).ToListAsync().ConfigureAwait(false);
foreach (var lodestoneAuth in aliasedUsers)
{
await CheckVanityForUser(restGuild, allowedRoleIds, db, lodestoneAuth, token).ConfigureAwait(false);
await Task.Delay(1000, token).ConfigureAwait(false);
}
foreach (var group in aliasedGroups)
{
await CheckVanityForGroup(restGuild, allowedRoleIds, db, group, token).ConfigureAwait(false);
await Task.Delay(1000, token).ConfigureAwait(false);
}
}
else
{
_logger.LogInformation("No roles for command defined, no cleanup performed");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Something failed during checking vanity user uids");
}
_logger.LogInformation("Vanity UID cleanup complete");
await Task.Delay(TimeSpan.FromHours(12), token).ConfigureAwait(false);
}
}
private async Task CheckVanityForGroup(RestGuild restGuild, Dictionary<ulong, string> allowedRoleIds, LightlessDbContext db, Group group, CancellationToken token)
{
var groupPrimaryUser = group.OwnerUID;
var groupOwner = await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.UserUID == group.OwnerUID).ConfigureAwait(false);
if (groupOwner != null && !string.IsNullOrEmpty(groupOwner.PrimaryUserUID))
{
groupPrimaryUser = groupOwner.PrimaryUserUID;
}
var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(f => f.User.UID == groupPrimaryUser).ConfigureAwait(false);
RestGuildUser discordUser = null;
if (lodestoneUser != null)
{
discordUser = await restGuild.GetUserAsync(lodestoneUser.DiscordId).ConfigureAwait(false);
}
_logger.LogInformation($"Checking Group: {group.GID} [{group.Alias}], owned by {group.OwnerUID} ({groupPrimaryUser}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List<ulong>())}");
if (lodestoneUser == null || discordUser == null || !discordUser.RoleIds.Any(allowedRoleIds.Keys.Contains))
{
await _botServices.LogToChannel($"VANITY GID REMOVAL: <@{lodestoneUser?.DiscordId ?? 0}> ({lodestoneUser?.User?.UID}) - GID: {group.GID}, Vanity: {group.Alias}").ConfigureAwait(false);
_logger.LogInformation($"User {lodestoneUser?.User?.UID ?? "unknown"} not in allowed roles, deleting group alias for {group.GID}");
group.Alias = null;
db.Update(group);
await db.SaveChangesAsync(token).ConfigureAwait(false);
}
}
private async Task CheckVanityForUser(RestGuild restGuild, Dictionary<ulong, string> allowedRoleIds, LightlessDbContext db, LodeStoneAuth lodestoneAuth, CancellationToken token)
{
var discordUser = await restGuild.GetUserAsync(lodestoneAuth.DiscordId).ConfigureAwait(false);
_logger.LogInformation($"Checking User: {lodestoneAuth.DiscordId}, {lodestoneAuth.User.UID} ({lodestoneAuth.User.Alias}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List<ulong>())}");
if (discordUser == null || !discordUser.RoleIds.Any(u => allowedRoleIds.Keys.Contains(u)))
{
_logger.LogInformation($"User {lodestoneAuth.User.UID} not in allowed roles, deleting alias");
await _botServices.LogToChannel($"VANITY UID REMOVAL: <@{lodestoneAuth.DiscordId}> - UID: {lodestoneAuth.User.UID}, Vanity: {lodestoneAuth.User.Alias}").ConfigureAwait(false);
lodestoneAuth.User.Alias = null;
var secondaryUsers = await db.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().ConfigureAwait(false);
foreach (var secondaryUser in secondaryUsers)
{
_logger.LogInformation($"Secondary User {secondaryUser.User.UID} not in allowed roles, deleting alias");
secondaryUser.User.Alias = null;
db.Update(secondaryUser.User);
}
db.Update(lodestoneAuth.User);
await db.SaveChangesAsync(token).ConfigureAwait(false);
}
}
private async Task UpdateStatusAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
var endPoint = _connectionMultiplexer.GetEndPoints().First();
var onlineUsers = await _connectionMultiplexer.GetServer(endPoint).KeysAsync(pattern: "UID:*").CountAsync().ConfigureAwait(false);
_logger.LogInformation("Users online: " + onlineUsers);
await _discordClient.SetActivityAsync(new Game("Lightless for " + onlineUsers + " Users")).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,175 @@
using System.Collections.Concurrent;
using Discord;
using Discord.Net;
using Discord.Rest;
using Discord.WebSocket;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using StackExchange.Redis;
namespace LightlessSyncServices.Discord;
public class DiscordBotServices
{
public readonly string[] LodestoneServers = ["eu", "na", "jp", "fr", "de"];
public ConcurrentDictionary<ulong, string> DiscordLodestoneMapping = new();
public ConcurrentDictionary<ulong, string> DiscordRelinkLodestoneMapping = new();
public ConcurrentDictionary<ulong, bool> DiscordVerifiedUsers { get; } = new();
public ConcurrentDictionary<ulong, DateTime> LastVanityChange = new();
public ConcurrentDictionary<string, DateTime> LastVanityGidChange = new(StringComparer.Ordinal);
public ConcurrentDictionary<ulong, ulong> ValidInteractions { get; } = new();
public ConcurrentDictionary<RestRole, string> VanityRoles { get; set; } = new();
public ConcurrentBag<ulong> VerifiedCaptchaUsers { get; } = new();
private readonly IConfigurationService<ServicesConfiguration> _configuration;
private readonly CancellationTokenSource verificationTaskCts = new();
private RestGuild? _guild;
private ulong? _logChannelId;
private RestTextChannel? _logChannel;
public DiscordBotServices(ILogger<DiscordBotServices> logger, LightlessMetrics metrics,
IConfigurationService<ServicesConfiguration> configuration)
{
Logger = logger;
Metrics = metrics;
_configuration = configuration;
}
public ILogger<DiscordBotServices> Logger { get; init; }
public LightlessMetrics Metrics { get; init; }
public ConcurrentQueue<KeyValuePair<ulong, Func<DiscordBotServices, Task>>> VerificationQueue { get; } = new();
public Task Start()
{
_ = ProcessVerificationQueue();
return Task.CompletedTask;
}
public Task Stop()
{
verificationTaskCts.Cancel();
verificationTaskCts.Dispose();
return Task.CompletedTask;
}
public async Task LogToChannel(string msg)
{
if (_guild == null) return;
Logger.LogInformation("LogToChannel: {msg}", msg);
var logChannelId = _configuration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForBotLog), null);
if (logChannelId == null) return;
if (logChannelId != _logChannelId)
{
try
{
_logChannelId = logChannelId;
_logChannel = await _guild.GetTextChannelAsync(logChannelId.Value).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogError(ex, "Could not get bot log channel");
}
}
if (_logChannel == null) return;
await _logChannel.SendMessageAsync(msg).ConfigureAwait(false);
}
private async Task RetryAsync(Task action, IUser user, string operation, bool logInfoToChannel = true)
{
int retryCount = 0;
int maxRetries = 5;
var retryDelay = TimeSpan.FromSeconds(5);
while (retryCount < maxRetries)
{
try
{
await action.ConfigureAwait(false);
if (logInfoToChannel)
await LogToChannel($"{user.Mention} {operation} SUCCESS").ConfigureAwait(false);
break;
}
catch (RateLimitedException)
{
retryCount++;
await LogToChannel($"{user.Mention} {operation} RATELIMIT, retry {retryCount} in {retryDelay}.").ConfigureAwait(false);
await Task.Delay(retryDelay).ConfigureAwait(false);
}
catch (Exception ex)
{
await LogToChannel($"{user.Mention} {operation} FAILED: {ex.Message}").ConfigureAwait(false);
break;
}
}
if (retryCount == maxRetries)
{
await LogToChannel($"{user.Mention} FAILED: RetryCount exceeded.").ConfigureAwait(false);
}
}
public async Task RemoveRegisteredRoleAsync(IUser user)
{
var registeredRole = _configuration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordRoleRegistered), null);
if (registeredRole == null) return;
var restUser = await _guild.GetUserAsync(user.Id).ConfigureAwait(false);
if (restUser == null) return;
if (!restUser.RoleIds.Contains(registeredRole.Value)) return;
await RetryAsync(restUser.RemoveRoleAsync(registeredRole.Value), user, $"Remove Registered Role").ConfigureAwait(false);
}
public async Task AddRegisteredRoleAsync(IUser user)
{
var registeredRole = _configuration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordRoleRegistered), null);
if (registeredRole == null) return;
var restUser = await _guild.GetUserAsync(user.Id).ConfigureAwait(false);
if (restUser == null) return;
if (restUser.RoleIds.Contains(registeredRole.Value)) return;
await RetryAsync(restUser.AddRoleAsync(registeredRole.Value), user, $"Add Registered Role").ConfigureAwait(false);
}
public async Task<bool> AddRegisteredRoleAsync(RestGuildUser user, RestRole role)
{
if (user.RoleIds.Contains(role.Id)) return false;
await RetryAsync(user.AddRoleAsync(role), user, $"Add Registered Role", false).ConfigureAwait(false);
return true;
}
public async Task KickUserAsync(RestGuildUser user)
{
await RetryAsync(user.KickAsync("No registration found"), user, "Kick").ConfigureAwait(false);
}
private async Task ProcessVerificationQueue()
{
while (!verificationTaskCts.IsCancellationRequested)
{
Logger.LogDebug("Processing Verification Queue, Entries: {entr}", VerificationQueue.Count);
if (VerificationQueue.TryPeek(out var queueitem))
{
try
{
await queueitem.Value.Invoke(this).ConfigureAwait(false);
Logger.LogInformation("Processed Verification for {key}", queueitem.Key);
}
catch (Exception e)
{
Logger.LogError(e, "Error during queue work");
}
finally
{
VerificationQueue.TryDequeue(out _);
}
}
await Task.Delay(TimeSpan.FromSeconds(2), verificationTaskCts.Token).ConfigureAwait(false);
}
}
internal void UpdateGuild(RestGuild guild)
{
_guild = guild;
}
}

View File

@@ -0,0 +1,15 @@
using Discord;
using Discord.Interactions;
namespace LightlessSyncServices.Discord;
// todo: remove all this crap at some point
public class LodestoneModal : IModal
{
public string Title => "Verify with Lodestone";
[InputLabel("Enter the Lodestone URL of your Character")]
[ModalTextInput("lodestone_url", TextInputStyle.Short, "https://*.finalfantasyxiv.com/lodestone/character/<CHARACTERID>/")]
public string LodestoneUrl { get; set; }
}

View File

@@ -0,0 +1,293 @@
using Discord;
using Discord.Interactions;
using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Services;
using StackExchange.Redis;
using LightlessSync.API.Data.Enum;
using LightlessSyncShared.Utils.Configuration;
namespace LightlessSyncServices.Discord;
public class LightlessModule : InteractionModuleBase
{
private readonly ILogger<LightlessModule> _logger;
private readonly IServiceProvider _services;
private readonly IConfigurationService<ServicesConfiguration> _lightlessServicesConfiguration;
private readonly IConnectionMultiplexer _connectionMultiplexer;
public LightlessModule(ILogger<LightlessModule> logger, IServiceProvider services,
IConfigurationService<ServicesConfiguration> lightlessServicesConfiguration,
IConnectionMultiplexer connectionMultiplexer)
{
_logger = logger;
_services = services;
_lightlessServicesConfiguration = lightlessServicesConfiguration;
_connectionMultiplexer = connectionMultiplexer;
}
[SlashCommand("userinfo", "Shows you your user information")]
public async Task UserInfo([Summary("secondary_uid", "(Optional) Your secondary UID")] string? secondaryUid = null,
[Summary("discord_user", "ADMIN ONLY: Discord User to check for")] IUser? discordUser = null,
[Summary("uid", "ADMIN ONLY: UID to check for")] string? uid = null)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}",
Context.Interaction.User.Id, nameof(UserInfo));
try
{
EmbedBuilder eb = new();
eb = await HandleUserInfo(eb, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid);
await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("useradd", "ADMIN ONLY: add a user unconditionally to the Database")]
public async Task UserAdd([Summary("desired_uid", "Desired UID")] string desiredUid)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{params}",
Context.Interaction.User.Id, nameof(UserAdd),
string.Join(",", new[] { $"{nameof(desiredUid)}:{desiredUid}" }));
try
{
var embed = await HandleUserAdd(desiredUid, Context.User.Id);
await RespondAsync(embeds: new[] { embed }, ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
EmbedBuilder eb = new();
eb.WithTitle("An error occured");
eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine);
await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false);
}
}
[SlashCommand("message", "ADMIN ONLY: sends a message to clients")]
public async Task SendMessageToClients([Summary("message", "Message to send")] string message,
[Summary("severity", "Severity of the message")] MessageSeverity messageType = MessageSeverity.Information,
[Summary("uid", "User ID to the person to send the message to")] string? uid = null)
{
_logger.LogInformation("SlashCommand:{userId}:{Method}:{message}:{type}:{uid}", Context.Interaction.User.Id, nameof(SendMessageToClients), message, messageType, uid);
using var scope = _services.CreateScope();
using var db = scope.ServiceProvider.GetService<LightlessDbContext>();
if (!(await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == Context.Interaction.User.Id))?.User?.IsAdmin ?? true)
{
await RespondAsync("No permission", ephemeral: true).ConfigureAwait(false);
return;
}
if (!string.IsNullOrEmpty(uid) && !await db.Users.AnyAsync(u => u.UID == uid))
{
await RespondAsync("Specified UID does not exist", ephemeral: true).ConfigureAwait(false);
return;
}
try
{
using HttpClient c = new HttpClient();
await c.PostAsJsonAsync(new Uri(_lightlessServicesConfiguration.GetValue<Uri>
(nameof(ServicesConfiguration.MainServerAddress)), "/msgc/sendMessage"), new ClientMessage(messageType, message, uid ?? string.Empty))
.ConfigureAwait(false);
var discordChannelForMessages = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordChannelForMessages), null);
if (uid == null && discordChannelForMessages != null)
{
var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value) as IMessageChannel;
if (discordChannel != null)
{
var embedColor = messageType switch
{
MessageSeverity.Information => Color.Blue,
MessageSeverity.Warning => new Color(255, 255, 0),
MessageSeverity.Error => Color.Red,
_ => Color.Blue
};
EmbedBuilder eb = new();
eb.WithTitle(messageType + " server message");
eb.WithColor(embedColor);
eb.WithDescription(message);
await discordChannel.SendMessageAsync(embed: eb.Build());
}
}
await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false);
}
catch (Exception ex)
{
await RespondAsync("Failed to send message: " + ex.ToString(), ephemeral: true).ConfigureAwait(false);
}
}
public async Task<Embed> HandleUserAdd(string desiredUid, ulong discordUserId)
{
var embed = new EmbedBuilder();
using var scope = _services.CreateScope();
using var db = scope.ServiceProvider.GetService<LightlessDbContext>();
if (!(await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == discordUserId))?.User?.IsAdmin ?? true)
{
embed.WithTitle("Failed to add user");
embed.WithDescription("No permission");
}
else if (db.Users.Any(u => u.UID == desiredUid || u.Alias == desiredUid))
{
embed.WithTitle("Failed to add user");
embed.WithDescription("Already in Database");
}
else
{
User newUser = new()
{
IsAdmin = false,
IsModerator = false,
LastLoggedIn = DateTime.UtcNow,
UID = desiredUid,
};
var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString());
var auth = new Auth()
{
HashedKey = StringUtils.Sha256String(computedHash),
User = newUser,
};
await db.Users.AddAsync(newUser);
await db.Auth.AddAsync(auth);
await db.SaveChangesAsync();
embed.WithTitle("Successfully added " + desiredUid);
embed.WithDescription("Secret Key: " + computedHash);
}
return embed.Build();
}
private async Task<EmbedBuilder> HandleUserInfo(EmbedBuilder eb, ulong id, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
{
bool showForSecondaryUser = secondaryUserUid != null;
using var scope = _services.CreateScope();
await using var db = scope.ServiceProvider.GetRequiredService<LightlessDbContext>();
var primaryUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == id).ConfigureAwait(false);
ulong userToCheckForDiscordId = id;
if (primaryUser == null)
{
eb.WithTitle("No account");
eb.WithDescription("No Lightless account was found associated to your Discord user");
return eb;
}
bool isAdminCall = primaryUser.User.IsModerator || primaryUser.User.IsAdmin;
if ((optionalUser != null || uid != null) && !isAdminCall)
{
eb.WithTitle("Unauthorized");
eb.WithDescription("You are not authorized to view another users' information");
return eb;
}
else if ((optionalUser != null || uid != null) && isAdminCall)
{
LodeStoneAuth userInDb = null;
if (optionalUser != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == optionalUser).ConfigureAwait(false);
}
else if (uid != null)
{
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid || u.User.Alias == uid).ConfigureAwait(false);
}
if (userInDb == null)
{
eb.WithTitle("No account");
eb.WithDescription("The Discord user has no valid Lightless account");
return eb;
}
userToCheckForDiscordId = userInDb.DiscordId;
}
var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == userToCheckForDiscordId).ConfigureAwait(false);
var dbUser = lodestoneUser.User;
if (showForSecondaryUser)
{
dbUser = (await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == dbUser.UID && u.UserUID == secondaryUserUid))?.User;
if (dbUser == null)
{
eb.WithTitle("No such secondary UID");
eb.WithDescription($"A secondary UID {secondaryUserUid} was not found attached to your primary UID {primaryUser.User.UID}.");
return eb;
}
}
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.WithTitle("User Information");
eb.WithDescription("This is the user information for Discord User <@" + userToCheckForDiscordId + ">" + Environment.NewLine + 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.");
eb.AddField("UID", dbUser.UID);
if (!string.IsNullOrEmpty(dbUser.Alias))
{
eb.AddField("Vanity UID", dbUser.Alias);
}
if (showForSecondaryUser)
{
eb.AddField("Primary UID for " + dbUser.UID, auth.PrimaryUserUID);
}
else
{
var secondaryUIDs = await db.Auth.Where(p => p.PrimaryUserUID == dbUser.UID).Select(p => p.UserUID).ToListAsync();
if (secondaryUIDs.Any())
{
eb.AddField("Secondary UIDs", string.Join(Environment.NewLine, secondaryUIDs));
}
}
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);
}
if (isAdminCall && !string.IsNullOrEmpty(identity))
{
eb.AddField("Character Ident", identity);
}
return eb;
}
}

View File

@@ -0,0 +1,215 @@
using Discord;
using Discord.Interactions;
using LightlessSyncShared.Utils.Configuration;
using System.Text.Json;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule : InteractionModuleBase
{
private const int _totalAprilFoolsRoles = 200;
private const string _persistentFileName = "april2024.json";
private static readonly SemaphoreSlim _fileSemaphore = new(1, 1);
[ComponentInteraction("wizard-fools")]
public async Task ComponentFools()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentFools), Context.Interaction.User.Id);
EmbedBuilder eb = new();
eb.WithTitle("WorryCoin™ and LightlessToken© Balance");
eb.WithColor(Color.Gold);
eb.WithDescription("You currently have" + Environment.NewLine + Environment.NewLine
+ "**200000** MaTE©" + Environment.NewLine
+ "**0** WorryCoin™" + Environment.NewLine + Environment.NewLine
+ "You have no payment method set up. Press the button below to add a payment method.");
ComponentBuilder cb = new();
AddHome(cb);
cb.WithButton("Add Payment Method", "wizard-fools-start", ButtonStyle.Primary, emote: new Emoji("💲"));
await ModifyInteraction(eb, cb).ConfigureAwait(false);
}
[ComponentInteraction("wizard-fools-start")]
public async Task ComponentFoolsStart()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentFoolsStart), Context.Interaction.User.Id);
EmbedBuilder eb = new();
var user = await Context.Guild.GetUserAsync(Context.User.Id).ConfigureAwait(false);
bool userIsInPermanentVanityRole = _botServices.VanityRoles.Where(v => !v.Value.Contains('$', StringComparison.Ordinal))
.Select(v => v.Key).Any(u => user.RoleIds.Contains(u.Id)) || !_botServices.VanityRoles.Any();
ComponentBuilder cb = new();
AddHome(cb);
var participatedUsers = await GetParticipants().ConfigureAwait(false);
var remainingRoles = _totalAprilFoolsRoles - participatedUsers.Count(c => c.Value == true);
if (userIsInPermanentVanityRole)
{
eb.WithColor(Color.Green);
eb.WithTitle("Happy April Fools!");
eb.WithDescription("Thank you for participating in Lightlesss 2024 April Fools event."
+ Environment.NewLine + Environment.NewLine
+ "As you might have already guessed from the post, nothing that was written there had any truth behind it."
+ Environment.NewLine + Environment.NewLine
+ "This entire thing was a jab at the ridiculousness of cryptocurrency, microtransactions and games featuring multiple currencies. I hope you enjoyed the announcement post!"
+ Environment.NewLine + Environment.NewLine
+ "__As you already have a role that gives you a permanent Vanity ID, you cannot win another one here. "
+ "However, tell your friends as this bot will give them a chance to win one of " + _totalAprilFoolsRoles + " lifetime vanity roles.__"
+ Environment.NewLine + Environment.NewLine
+ "The giveaway is active until <t:" + (new DateTime(2024, 04, 01, 23, 59, 59, DateTimeKind.Utc).Subtract(DateTime.UnixEpoch).TotalSeconds) + ":f>.");
}
else if (participatedUsers.ContainsKey(Context.User.Id))
{
eb.WithColor(Color.Orange);
eb.WithTitle("Happy April Fools!");
eb.WithDescription("Thank you for participating in Lightlesss 2024 April Fools event."
+ Environment.NewLine + Environment.NewLine
+ "As you might have already guessed from the post, nothing that was written there had any truth behind it."
+ Environment.NewLine + Environment.NewLine
+ "This entire thing was a jab at the ridiculousness of cryptocurrency, microtransactions and games featuring multiple currencies. I hope you enjoyed the announcement post!"
+ Environment.NewLine + Environment.NewLine
+ "__You already participated in the giveaway of the permanent Vanity roles and therefore cannot participate again. Better luck next time!__");
}
else if (remainingRoles > 0)
{
eb.WithColor(Color.Green);
eb.WithTitle("Happy April Fools!");
eb.WithDescription("Thank you for participating in Lightlesss 2024 April Fools event."
+ Environment.NewLine + Environment.NewLine
+ "As you might have already guessed from the post, nothing that was written there had any truth behind it."
+ Environment.NewLine + Environment.NewLine
+ "This entire thing was a jab at the ridiculousness of cryptocurrency, microtransactions and games featuring multiple currencies. I hope you enjoyed the announcement post!"
+ Environment.NewLine + Environment.NewLine
+ "You have currently no permanent role that allows you to set a Vanity ID, however I am giving away a total of " + _totalAprilFoolsRoles + " permanent vanity roles "
+ "(" + remainingRoles + " still remain) and you can win one using this bot!"
+ Environment.NewLine + Environment.NewLine
+ "To win you simply have to pick one of the buttons labeled \"Win\" below this post. Which button will win is random. "
+ "There is a 1 in 5 chance that you can win the role. __You can only participate once.__"
+ Environment.NewLine + Environment.NewLine
+ "The giveaway is active until <t:" + (new DateTime(2024, 04, 01, 23, 59, 59, DateTimeKind.Utc).Subtract(DateTime.UnixEpoch).TotalSeconds) + ":f>.");
cb.WithButton("Win", "wizard-fools-win:1", ButtonStyle.Primary, new Emoji("1⃣"));
cb.WithButton("Win", "wizard-fools-win:2", ButtonStyle.Primary, new Emoji("2⃣"));
cb.WithButton("Win", "wizard-fools-win:3", ButtonStyle.Primary, new Emoji("3⃣"));
cb.WithButton("Win", "wizard-fools-win:4", ButtonStyle.Primary, new Emoji("4⃣"));
cb.WithButton("Win", "wizard-fools-win:5", ButtonStyle.Primary, new Emoji("5⃣"));
}
else
{
eb.WithColor(Color.Orange);
eb.WithTitle("Happy April Fools!");
eb.WithDescription("Thank you for participating in Lightlesss 2024 April Fools event."
+ Environment.NewLine + Environment.NewLine
+ "As you might have already guessed from the post, nothing that was written there had any truth behind it."
+ Environment.NewLine + Environment.NewLine
+ "This entire thing was a jab at the ridiculousness of cryptocurrency, microtransactions and games featuring multiple currencies. I hope you enjoyed the announcement post!"
+ Environment.NewLine + Environment.NewLine
+ "__I have been giving away " + _totalAprilFoolsRoles + " permanent Vanity ID roles for this server, however you are sadly too late as they ran out by now. "
+ "Better luck next year with whatever I will come up with!__");
}
await ModifyInteraction(eb, cb).ConfigureAwait(false);
}
[ComponentInteraction("wizard-fools-win:*")]
public async Task ComponentFoolsWin(int number)
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentFoolsWin), Context.Interaction.User.Id);
var winningNumber = new Random().Next(1, 6);
EmbedBuilder eb = new();
ComponentBuilder cb = new();
AddHome(cb);
bool hasWon = winningNumber == number;
await WriteParticipants(Context.Interaction.User.Id, hasWon).ConfigureAwait(false);
if (hasWon)
{
eb.WithColor(Color.Gold);
eb.WithTitle("Congratulations you are winner!");
eb.WithDescription("You, by pure accident and sheer luck, picked the right number and have won yourself a lifetime Vanity ID role on this server!"
+ Environment.NewLine + Environment.NewLine
+ "The role will remain as long as you remain on this server, if you happen to leave it you will not get the role back."
+ Environment.NewLine + Environment.NewLine
+ "Head over to Home and to the Vanity IDs section to set it up for your account!"
+ Environment.NewLine + Environment.NewLine
+ "Once again, thank you for participating and have a great day.");
var user = await Context.Guild.GetUserAsync(Context.User.Id).ConfigureAwait(false);
await user.AddRoleAsync(_lightlessServicesConfiguration.GetValue<ulong?>(nameof(ServicesConfiguration.DiscordRoleAprilFools2024)).Value).ConfigureAwait(false);
}
else
{
eb.WithColor(Color.Red);
eb.WithTitle("Fortune did not bless you");
eb.WithDescription("You, through sheer misfortune, sadly did not pick the right number. (The winning number was " + winningNumber + ")"
+ Environment.NewLine + Environment.NewLine
+ "Better luck next time!"
+ Environment.NewLine + Environment.NewLine
+ "Once again, thank you for participating and regardless, have a great day.");
}
await ModifyInteraction(eb, cb).ConfigureAwait(false);
}
private async Task<Dictionary<ulong, bool>> GetParticipants()
{
await _fileSemaphore.WaitAsync().ConfigureAwait(false);
try
{
if (!File.Exists(_persistentFileName))
{
return new();
}
var json = await File.ReadAllTextAsync(_persistentFileName).ConfigureAwait(false);
return JsonSerializer.Deserialize<Dictionary<ulong, bool>>(json);
}
catch
{
return new();
}
finally
{
_fileSemaphore.Release();
}
}
private async Task WriteParticipants(ulong participant, bool win)
{
await _fileSemaphore.WaitAsync().ConfigureAwait(false);
try
{
Dictionary<ulong, bool> participants = new();
if (File.Exists(_persistentFileName))
{
try
{
var json = await File.ReadAllTextAsync(_persistentFileName).ConfigureAwait(false);
participants = JsonSerializer.Deserialize<Dictionary<ulong, bool>>(json);
}
catch
{
// probably empty file just deal with it
}
}
participants[participant] = win;
await File.WriteAllTextAsync(_persistentFileName, JsonSerializer.Serialize(participants)).ConfigureAwait(false);
}
finally
{
_fileSemaphore.Release();
}
}
}

View File

@@ -0,0 +1,119 @@
using Discord.Interactions;
using Discord;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using Discord.WebSocket;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule
{
[ComponentInteraction("wizard-delete")]
public async Task ComponentDelete()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentDelete), Context.Interaction.User.Id);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
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(lightlessDb, 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;
_logger.LogInformation("{method}:{userId}:{uid}", nameof(SelectionDeleteAccount), Context.Interaction.User.Id, uid);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
bool isPrimary = lightlessDb.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;
_logger.LogInformation("{method}:{userId}:{uid}", nameof(ComponentDeleteAccountConfirm), Context.Interaction.User.Id, uid);
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;
_logger.LogInformation("{method}:{userId}:{uid}", nameof(ModalDeleteAccountConfirm), Context.Interaction.User.Id, uid);
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 = _lightlessClientConfigurationService.GetValueOrDefault(nameof(ServerConfiguration.MaxGroupUserCount), 3);
using var db = await GetDbContext().ConfigureAwait(false);
var user = await db.Users.SingleAsync(u => u.UID == uid).ConfigureAwait(false);
var lodestone = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid).ConfigureAwait(false);
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);
await _botServices.LogToChannel($"{Context.User.Mention} DELETE SUCCESS: {uid}").ConfigureAwait(false);
// only remove role if deleted uid has lodestone attached (== primary uid)
if (lodestone != null)
{
await _botServices.RemoveRegisteredRoleAsync(Context.Interaction.User).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling modal delete account confirm");
}
}
}

View File

@@ -0,0 +1,90 @@
using Discord.Interactions;
using Discord;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule
{
[ComponentInteraction("wizard-recover")]
public async Task ComponentRecover()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentRecover), Context.Interaction.User.Id);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
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
+ "## ⚠️ **Once you recover your key, the previously used key will be invalidated. If you use Lightless on multiple devices you will have to update the key everywhere you use it.** ⚠️" + 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." + Environment.NewLine
+ "# Note: instead of recovery and handling secret keys the switch to OAuth2 authentication is strongly suggested.");
ComponentBuilder cb = new();
await AddUserSelection(lightlessDb, 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;
_logger.LogInformation("{method}:{userId}:{uid}", nameof(SelectionRecovery), Context.Interaction.User.Id, uid);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
EmbedBuilder eb = new();
eb.WithColor(Color.Green);
await HandleRecovery(lightlessDb, eb, uid).ConfigureAwait(false);
ComponentBuilder cb = new();
AddHome(cb);
await ModifyInteraction(eb, cb).ConfigureAwait(false);
}
private async Task HandleRecovery(LightlessDbContext 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());
string hashedKey = StringUtils.Sha256String(computedHash);
auth = new Auth()
{
HashedKey = hashedKey,
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
+ "**__NOTE: Secret keys are considered legacy authentication. If you are using the suggested OAuth2 authentication, you do not need to use the Secret Key or recover ever again.__**"
+ Environment.NewLine + Environment.NewLine
+ $"||**`{computedHash}`**||"
+ Environment.NewLine
+ "__NOTE: The Secret Key only contains the letters ABCDEF and numbers 0 - 9.__"
+ Environment.NewLine + Environment.NewLine
+ "Enter this key in the Lightless Sync Service Settings and reconnect to the service.");
await db.Auth.AddAsync(auth).ConfigureAwait(false);
await db.SaveChangesAsync().ConfigureAwait(false);
_botServices.Logger.LogInformation("User recovered: {userUID}:{hashedKey}", previousAuth.UserUID, hashedKey);
await _botServices.LogToChannel($"{Context.User.Mention} RECOVER SUCCESS: {previousAuth.UserUID}").ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,311 @@
using Discord.Interactions;
using Discord;
using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using Discord.Rest;
using Discord.WebSocket;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule
{
[ComponentInteraction("wizard-register")]
public async Task ComponentRegister()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentRegister), Context.Interaction.User.Id);
EmbedBuilder eb = new();
eb.WithColor(Color.Blue);
eb.WithTitle("Start Registration");
eb.WithDescription("Here you can start the registration process with the Lightless Sync 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
+ "- Do not use this on mobile because you will need to be able to copy the generated secret key" + Environment.NewLine
+ "# Follow the bot instructions precisely. Slow down and read.");
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;
_logger.LogInformation("{method}:{userId}", nameof(ComponentRegisterStart), Context.Interaction.User.Id);
using var db = await GetDbContext().ConfigureAwait(false);
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;
_logger.LogInformation("{method}:{userId}:{lodestone}", nameof(ModalRegister), Context.Interaction.User.Id, lodestoneModal.LodestoneUrl);
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;
_logger.LogInformation("{method}:{userId}:{verificationcode}", nameof(ComponentRegisterVerify), Context.Interaction.User.Id, verificationCode);
_botServices.VerificationQueue.Enqueue(new KeyValuePair<ulong, Func<DiscordBotServices, Task>>(Context.User.Id,
(service) => HandleVerifyAsync(Context.User.Id, verificationCode, service)));
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;
_logger.LogInformation("{method}:{userId}:{uid}", nameof(ComponentRegisterVerifyCheck), Context.Interaction.User.Id, verificationCode);
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);
bool registerSuccess = false;
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 = await GetDbContext().ConfigureAwait(false);
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
+ "**__NOTE: Secret keys are considered legacy. Using the suggested OAuth2 authentication in Lightless, you do not need to use this Secret Key.__**"
+ Environment.NewLine + Environment.NewLine
+ $"||**`{key}`**||"
+ Environment.NewLine + Environment.NewLine
+ "If you want to continue using legacy authentication, enter this key in Lightless Sync and hit save to connect to the service."
+ Environment.NewLine
+ "__NOTE: The Secret Key only contains the letters ABCDEF and numbers 0 - 9.__"
+ Environment.NewLine
+ "You should connect as soon as possible to not get caught by the automatic cleanup process."
+ Environment.NewLine
+ "Have fun.");
AddHome(cb);
registerSuccess = true;
}
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
+ "If this link does not lead to your profile edit page, you __need__ to configure the privacy settings first: https://na.finalfantasyxiv.com/lodestone/my/setting/profile/"
+ Environment.NewLine + Environment.NewLine
+ "**Make sure your profile is set to public (All Users) for your character. The bot cannot read profiles with privacy settings set to \"logged in\" or \"private\".**"
+ Environment.NewLine + Environment.NewLine
+ "## You __need__ to enter following the code this bot provided onto your Lodestone in the character profile:"
+ 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);
if (registerSuccess)
await _botServices.AddRegisteredRoleAsync(Context.Interaction.User).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
var hashedLodestoneId = StringUtils.Sha256String(lodestoneId.ToString());
using var db = await GetDbContext().ConfigureAwait(false);
// 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
+ "__NOTE: If the link does not lead you to your character edit profile page, you need to log in and set up your privacy settings!__"
+ Environment.NewLine + Environment.NewLine
+ $"**`{lodestoneAuth}`**"
+ Environment.NewLine + Environment.NewLine
+ $"**! THIS IS NOT THE KEY YOU HAVE TO ENTER IN LIGHTLESS !**"
+ 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 Lightless Sync."
+ 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, DiscordBotServices services)
{
using var req = new HttpClient();
services.DiscordVerifiedUsers.Remove(userid, out _);
if (services.DiscordLodestoneMapping.ContainsKey(userid))
{
var randomServer = services.LodestoneServers[random.Next(services.LodestoneServers.Length)];
var url = $"https://{randomServer}.finalfantasyxiv.com/lodestone/character/{services.DiscordLodestoneMapping[userid]}";
using var response = await req.GetAsync(url).ConfigureAwait(false);
_logger.LogInformation("Verifying {userid} with URL {url}", userid, url);
if (response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (content.Contains(authString))
{
services.DiscordVerifiedUsers[userid] = true;
_logger.LogInformation("Verified {userid} from lodestone {lodestone}", userid, services.DiscordLodestoneMapping[userid]);
await _botServices.LogToChannel($"<@{userid}> REGISTER VERIFY: Success.").ConfigureAwait(false);
services.DiscordLodestoneMapping.TryRemove(userid, out _);
}
else
{
services.DiscordVerifiedUsers[userid] = false;
_logger.LogInformation("Could not verify {userid} from lodestone {lodestone}, did not find authString: {authString}, status code was: {code}",
userid, services.DiscordLodestoneMapping[userid], authString, response.StatusCode);
await _botServices.LogToChannel($"<@{userid}> REGISTER VERIFY: Failed: No Authstring ({authString}). (<{url}>)").ConfigureAwait(false);
}
}
else
{
_logger.LogWarning("Could not verify {userid}, HttpStatusCode: {code}", userid, response.StatusCode);
await _botServices.LogToChannel($"<@{userid}> REGISTER VERIFY: Failed: HttpStatusCode {response.StatusCode}. (<{url}>)").ConfigureAwait(false);
}
}
}
private async Task<(string, string)> HandleAddUser(LightlessDbContext 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());
string hashedKey = StringUtils.Sha256String(computedHash);
var auth = new Auth()
{
HashedKey = hashedKey,
User = user,
};
await db.Users.AddAsync(user).ConfigureAwait(false);
await db.Auth.AddAsync(auth).ConfigureAwait(false);
lodestoneAuth.StartedAt = null;
lodestoneAuth.User = user;
lodestoneAuth.LodestoneAuthString = null;
await db.SaveChangesAsync().ConfigureAwait(false);
_botServices.Logger.LogInformation("User registered: {userUID}:{hashedKey}", user.UID, hashedKey);
await _botServices.LogToChannel($"{Context.User.Mention} REGISTER COMPLETE: => {user.UID}").ConfigureAwait(false);
_botServices.DiscordVerifiedUsers.Remove(Context.User.Id, out _);
return (user.UID, computedHash);
}
}

View File

@@ -0,0 +1,281 @@
using Discord.Interactions;
using Discord;
using LightlessSyncShared.Data;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Models;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule
{
[ComponentInteraction("wizard-relink")]
public async Task ComponentRelink()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentRelink), Context.Interaction.User.Id);
EmbedBuilder eb = new();
eb.WithTitle("Relink");
eb.WithColor(Color.Blue);
eb.WithDescription("Use this in case you already have a registered Lightless 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;
_logger.LogInformation("{method}:{userId}", nameof(ComponentRelinkStart), Context.Interaction.User.Id);
using var db = await GetDbContext().ConfigureAwait(false);
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;
_logger.LogInformation("{method}:{userId}:{url}", nameof(ModalRelink), Context.Interaction.User.Id, lodestoneModal.LodestoneUrl);
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;
_logger.LogInformation("{method}:{userId}:{uid}:{verificationCode}", nameof(ComponentRelinkVerify), Context.Interaction.User.Id, uid, verificationCode);
_botServices.VerificationQueue.Enqueue(new KeyValuePair<ulong, Func<DiscordBotServices, Task>>(Context.User.Id,
(services) => HandleVerifyRelinkAsync(Context.User.Id, verificationCode, services)));
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;
_logger.LogInformation("{method}:{userId}:{uid}:{verificationCode}", nameof(ComponentRelinkVerifyCheck), Context.Interaction.User.Id, uid, verificationCode);
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);
bool relinkSuccess = false;
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 + "," + uid, 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 = await GetDbContext().ConfigureAwait(false);
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 Lightless Sync and hit save to connect to the service."
+ Environment.NewLine + Environment.NewLine
+ "NOTE: If you are using OAuth2, you do not require to use this secret key."
+ Environment.NewLine
+ "Have fun.");
AddHome(cb);
relinkSuccess = true;
}
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
+ "**Make sure your profile is set to public (All Users) for your character. The bot cannot read profiles with privacy settings set to \"logged in\" or \"private\".**" + 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);
if (relinkSuccess)
await _botServices.AddRegisteredRoleAsync(Context.Interaction.User).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
var hashedLodestoneId = StringUtils.Sha256String(lodestoneId.ToString());
using var db = await GetDbContext().ConfigureAwait(false);
// 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 LIGHTLESS !**"
+ 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, DiscordBotServices services)
{
using var req = new HttpClient();
services.DiscordVerifiedUsers.Remove(userid, out _);
if (services.DiscordRelinkLodestoneMapping.ContainsKey(userid))
{
var randomServer = services.LodestoneServers[random.Next(services.LodestoneServers.Length)];
var url = $"https://{randomServer}.finalfantasyxiv.com/lodestone/character/{services.DiscordRelinkLodestoneMapping[userid]}";
_logger.LogInformation("Verifying {userid} with URL {url}", userid, url);
using var response = await req.GetAsync(url).ConfigureAwait(false);
if (response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (content.Contains(authString))
{
services.DiscordVerifiedUsers[userid] = true;
_logger.LogInformation("Relink: Verified {userid} from lodestone {lodestone}", userid, services.DiscordRelinkLodestoneMapping[userid]);
await _botServices.LogToChannel($"<@{userid}> RELINK VERIFY: Success.").ConfigureAwait(false);
services.DiscordRelinkLodestoneMapping.TryRemove(userid, out _);
}
else
{
services.DiscordVerifiedUsers[userid] = false;
_logger.LogInformation("Relink: Could not verify {userid} from lodestone {lodestone}, did not find authString: {authString}, status code was: {code}",
userid, services.DiscordRelinkLodestoneMapping[userid], authString, response.StatusCode);
await _botServices.LogToChannel($"<@{userid}> RELINK VERIFY: Failed: No Authstring ({authString}). (<{url}>)").ConfigureAwait(false);
}
}
else
{
_logger.LogWarning("Could not verify {userid}, HttpStatusCode: {code}", userid, response.StatusCode);
await _botServices.LogToChannel($"<@{userid}> RELINK VERIFY: Failed: HttpStatusCode {response.StatusCode}. (<{url}>)").ConfigureAwait(false);
}
}
}
private async Task<(string, string)> HandleRelinkUser(LightlessDbContext 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);
await _botServices.LogToChannel($"{Context.User.Mention} RELINK COMPLETE: => {user.UID}").ConfigureAwait(false);
return (user.UID, computedHash);
}
}

View File

@@ -0,0 +1,91 @@
using Discord.Interactions;
using Discord;
using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule
{
[ComponentInteraction("wizard-secondary")]
public async Task ComponentSecondary()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentSecondary), Context.Interaction.User.Id);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
var primaryUID = (await lightlessDb.LodeStoneAuth.Include(u => u.User).SingleAsync(u => u.DiscordId == Context.User.Id).ConfigureAwait(false)).User.UID;
var secondaryUids = await lightlessDb.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 Lightless accounts with their own pair list, joined syncshells, UID and so on." + Environment.NewLine
+ "Use this to create UIDs if you want to use Lightless 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 Lightless for alts." + Environment.NewLine + Environment.NewLine
+ $"You currently have {secondaryUids} Secondary UIDs out of a maximum of 20.");
ComponentBuilder cb = new();
AddHome(cb);
cb.WithButton("Create Secondary UID", "wizard-secondary-create:" + primaryUID, ButtonStyle.Primary, emote: new Emoji("2⃣"), disabled: secondaryUids >= 20);
await ModifyInteraction(eb, cb).ConfigureAwait(false);
}
[ComponentInteraction("wizard-secondary-create:*")]
public async Task ComponentSecondaryCreate(string primaryUid)
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}:{primary}", nameof(ComponentSecondaryCreate), Context.Interaction.User.Id, primaryUid);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
EmbedBuilder eb = new();
eb.WithTitle("Secondary UID created");
eb.WithColor(Color.Green);
ComponentBuilder cb = new();
AddHome(cb);
await HandleAddSecondary(lightlessDb, eb, primaryUid).ConfigureAwait(false);
await ModifyInteraction(eb, cb).ConfigureAwait(false);
}
public async Task HandleAddSecondary(LightlessDbContext 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 Lightless setings in the Service Settings tab.");
embed.AddField("UID", newUser.UID);
embed.AddField("Secret Key", computedHash);
await _botServices.LogToChannel($"{Context.User.Mention} SECONDARY SUCCESS: {newUser.UID}").ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,80 @@
using Discord.Interactions;
using Discord;
using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule
{
[ComponentInteraction("wizard-userinfo")]
public async Task ComponentUserinfo()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentUserinfo), Context.Interaction.User.Id);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
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(lightlessDb, 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;
_logger.LogInformation("{method}:{userId}:{uid}", nameof(SelectionUserinfo), Context.Interaction.User.Id, uid);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
EmbedBuilder eb = new();
eb.WithTitle($"User Info for {uid}");
await HandleUserInfo(eb, lightlessDb, uid).ConfigureAwait(false);
eb.WithColor(Color.Green);
ComponentBuilder cb = new();
await AddUserSelection(lightlessDb, cb, "wizard-userinfo-select").ConfigureAwait(false);
AddHome(cb);
await ModifyInteraction(eb, cb).ConfigureAwait(false);
}
private async Task HandleUserInfo(EmbedBuilder eb, LightlessDbContext db, string uid)
{
ulong userToCheckForDiscordId = Context.User.Id;
var dbUser = await db.Users.SingleOrDefaultAsync(u => u.UID == 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.");
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("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);
}
}
}

View File

@@ -0,0 +1,205 @@
using Discord.Interactions;
using Discord;
using Microsoft.EntityFrameworkCore;
using System.Text.RegularExpressions;
using System.Text;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule
{
[ComponentInteraction("wizard-vanity")]
public async Task ComponentVanity()
{
if (!(await ValidateInteraction().ConfigureAwait(false))) return;
_logger.LogInformation("{method}:{userId}", nameof(ComponentVanity), Context.Interaction.User.Id);
StringBuilder sb = new();
var user = await Context.Guild.GetUserAsync(Context.User.Id).ConfigureAwait(false);
bool userIsInVanityRole = _botServices.VanityRoles.Keys.Any(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("- ").Append(role.Key.Mention).Append(" (").Append(role.Value).AppendLine(")");
}
}
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 = await GetDbContext().ConfigureAwait(false);
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;
_logger.LogInformation("{method}:{userId}:{uid}", nameof(SelectionVanityUid), Context.Interaction.User.Id, uid);
using var db = await GetDbContext().ConfigureAwait(false);
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;
_logger.LogInformation("{method}:{userId}:{uid}", nameof(SelectionVanityUidSet), Context.Interaction.User.Id, uid);
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;
_logger.LogInformation("{method}:{userId}:{uid}:{vanity}", nameof(ConfirmVanityUidModal), Context.Interaction.User.Id, uid, modal.DesiredVanityUID);
EmbedBuilder eb = new();
ComponentBuilder cb = new();
var desiredVanityUid = modal.DesiredVanityUID;
using var db = await GetDbContext().ConfigureAwait(false);
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 Lightless service.");
await _botServices.LogToChannel($"{Context.User.Mention} VANITY UID SET: UID: {user.UID}, Vanity: {desiredVanityUid}").ConfigureAwait(false);
AddHome(cb);
}
await ModifyModalInteraction(eb, cb).ConfigureAwait(false);
}
[ComponentInteraction("wizard-vanity-gid")]
public async Task SelectionVanityGid(string gid)
{
_logger.LogInformation("{method}:{userId}:{uid}", nameof(SelectionVanityGid), Context.Interaction.User.Id, gid);
using var db = await GetDbContext().ConfigureAwait(false);
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;
_logger.LogInformation("{method}:{userId}:{gid}", nameof(SelectionVanityGidSet), Context.Interaction.User.Id, gid);
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;
_logger.LogInformation("{method}:{userId}:{gid}:{vanity}", nameof(ConfirmVanityGidModal), Context.Interaction.User.Id, gid, modal.DesiredVanityGID);
EmbedBuilder eb = new();
ComponentBuilder cb = new();
var desiredVanityGid = modal.DesiredVanityGID;
using var db = await GetDbContext().ConfigureAwait(false);
bool canAddVanityId = !db.Groups.Any(u => u.GID == modal.DesiredVanityGID || u.Alias == modal.DesiredVanityGID);
Regex rgx = new(@"^[_\-a-zA-Z0-9]{5,20}$", RegexOptions.ECMAScript);
if (!rgx.Match(desiredVanityGid).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 \"{desiredVanityGid}\" 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 = desiredVanityGid;
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 \"{desiredVanityGid}\"." + Environment.NewLine + Environment.NewLine
+ "For changes to take effect you need to reconnect to the Lightless service.");
AddHome(cb);
await _botServices.LogToChannel($"{Context.User.Mention} VANITY GID SET: GID: {group.GID}, Vanity: {desiredVanityGid}").ConfigureAwait(false);
}
await ModifyModalInteraction(eb, cb).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,346 @@
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
using System.Text.RegularExpressions;
namespace LightlessSyncServices.Discord;
public partial class LightlessWizardModule : InteractionModuleBase
{
private ILogger<LightlessModule> _logger;
private DiscordBotServices _botServices;
private IConfigurationService<ServerConfiguration> _lightlessClientConfigurationService;
private IConfigurationService<ServicesConfiguration> _lightlessServicesConfiguration;
private IConnectionMultiplexer _connectionMultiplexer;
private readonly IDbContextFactory<LightlessDbContext> _dbContextFactory;
private Random random = new();
public LightlessWizardModule(ILogger<LightlessModule> logger, DiscordBotServices botServices,
IConfigurationService<ServerConfiguration> lightlessClientConfigurationService,
IConfigurationService<ServicesConfiguration> lightlessServicesConfiguration,
IConnectionMultiplexer connectionMultiplexer, IDbContextFactory<LightlessDbContext> dbContextFactory)
{
_logger = logger;
_botServices = botServices;
_lightlessClientConfigurationService = lightlessClientConfigurationService;
_lightlessServicesConfiguration = lightlessServicesConfiguration;
_connectionMultiplexer = connectionMultiplexer;
_dbContextFactory = dbContextFactory;
}
[ComponentInteraction("wizard-captcha:*")]
public async Task WizardCaptcha(bool init = false)
{
if (!init && !(await ValidateInteraction().ConfigureAwait(false))) return;
if (_botServices.VerifiedCaptchaUsers.Contains(Context.Interaction.User.Id))
{
await StartWizard(true).ConfigureAwait(false);
return;
}
EmbedBuilder eb = new();
Random rnd = new Random();
var correctButton = rnd.Next(4) + 1;
string nthButtonText = correctButton switch
{
1 => "first",
2 => "second",
3 => "third",
4 => "fourth",
_ => "unknown",
};
Emoji nthButtonEmoji = correctButton switch
{
1 => new Emoji("⬅️"),
2 => new Emoji("🤖"),
3 => new Emoji("‼️"),
4 => new Emoji("✉️"),
_ => "unknown",
};
eb.WithTitle("Lightless Bot Services Captcha");
eb.WithDescription("You are seeing this embed because you interact with this bot for the first time since the bot has been restarted." + Environment.NewLine + Environment.NewLine
+ "This bot __requires__ embeds for its function. To proceed, please verify you have embeds enabled." + Environment.NewLine
+ $"## To verify you have embeds enabled __press on the **{nthButtonText}** button ({nthButtonEmoji}).__");
eb.WithColor(Color.LightOrange);
int incorrectButtonHighlight = 1;
do
{
incorrectButtonHighlight = rnd.Next(4) + 1;
}
while (incorrectButtonHighlight == correctButton);
ComponentBuilder cb = new();
cb.WithButton("This", correctButton == 1 ? "wizard-home:false" : "wizard-captcha-fail:1", emote: new Emoji("⬅️"), style: incorrectButtonHighlight == 1 ? ButtonStyle.Primary : ButtonStyle.Secondary);
cb.WithButton("Bot", correctButton == 2 ? "wizard-home:false" : "wizard-captcha-fail:2", emote: new Emoji("🤖"), style: incorrectButtonHighlight == 2 ? ButtonStyle.Primary : ButtonStyle.Secondary);
cb.WithButton("Requires", correctButton == 3 ? "wizard-home:false" : "wizard-captcha-fail:3", emote: new Emoji("‼️"), style: incorrectButtonHighlight == 3 ? ButtonStyle.Primary : ButtonStyle.Secondary);
cb.WithButton("Embeds", correctButton == 4 ? "wizard-home:false" : "wizard-captcha-fail:4", emote: new Emoji("✉️"), style: incorrectButtonHighlight == 4 ? ButtonStyle.Primary : ButtonStyle.Secondary);
await InitOrUpdateInteraction(init, eb, cb).ConfigureAwait(false);
}
private async Task InitOrUpdateInteraction(bool init, EmbedBuilder eb, ComponentBuilder cb)
{
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);
}
}
[ComponentInteraction("wizard-captcha-fail:*")]
public async Task WizardCaptchaFail(int button)
{
ComponentBuilder cb = new();
cb.WithButton("Restart (with Embeds enabled)", "wizard-captcha:false", emote: new Emoji("↩️"));
await ((Context.Interaction) as IComponentInteraction).UpdateAsync(m =>
{
m.Embed = null;
m.Content = "You pressed the wrong button. You likely have embeds disabled. Enable embeds in your Discord client (Settings -> Chat -> \"Show embeds and preview website links pasted into chat\") and try again.";
m.Components = cb.Build();
}).ConfigureAwait(false);
await _botServices.LogToChannel($"{Context.User.Mention} FAILED CAPTCHA").ConfigureAwait(false);
}
[ComponentInteraction("wizard-home:*")]
public async Task StartWizard(bool init = false)
{
if (!init && !(await ValidateInteraction().ConfigureAwait(false))) return;
if (!_botServices.VerifiedCaptchaUsers.Contains(Context.Interaction.User.Id))
_botServices.VerifiedCaptchaUsers.Add(Context.Interaction.User.Id);
_logger.LogInformation("{method}:{userId}", nameof(StartWizard), Context.Interaction.User.Id);
using var lightlessDb = await GetDbContext().ConfigureAwait(false);
bool hasAccount = await lightlessDb.LodeStoneAuth.AnyAsync(u => u.DiscordId == Context.User.Id && u.StartedAt == null).ConfigureAwait(false);
if (init)
{
bool isBanned = await lightlessDb.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;
}
}
#if !DEBUG
bool isInAprilFoolsMode = _lightlessServicesConfiguration.GetValueOrDefault<ulong?>(nameof(ServicesConfiguration.DiscordRoleAprilFools2024), null) != null
&& DateTime.UtcNow.Month == 4 && DateTime.UtcNow.Day == 1 && DateTime.UtcNow.Year == 2024 && DateTime.UtcNow.Hour >= 10;
#elif DEBUG
bool isInAprilFoolsMode = true;
#endif
EmbedBuilder eb = new();
eb.WithTitle("Welcome to the Lightless Sync 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 Lightless 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 : (!isInAprilFoolsMode ? string.Empty : ("- Check your WorryCoin™ and LightlessToken© balance and add payment options" + 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("💅"));
if (isInAprilFoolsMode)
{
cb.WithButton("WorryCoin™ and LightlessToken© management", "wizard-fools", ButtonStyle.Primary, new Emoji("💲"));
}
cb.WithButton("Delete", "wizard-delete", ButtonStyle.Danger, new Emoji("⚠️"));
}
await InitOrUpdateInteraction(init, 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 async Task<LightlessDbContext> GetDbContext()
{
return await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
}
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.Content = null;
m.Embed = eb.Build();
m.Components = cb.Build();
}).ConfigureAwait(false);
}
private async Task AddUserSelection(LightlessDbContext lightlessDb, ComponentBuilder cb, string customId)
{
var discordId = Context.User.Id;
var existingAuth = await lightlessDb.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 lightlessDb.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(LightlessDbContext 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, LightlessDbContext dbContext)
{
var auth = StringUtils.GenerateRandomString(12, "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz");
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

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Content Remove="appsettings.Development.json" />
<Content Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.Development.json" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.json">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Discord.Net" Version="3.17.0" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.184">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" />
<ProjectReference Include="..\LightlessSyncShared\LightlessSyncShared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,57 @@
using LightlessSyncServices;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
public class Program
{
public static void Main(string[] args)
{
var hostBuilder = CreateHostBuilder(args);
var host = hostBuilder.Build();
using (var scope = host.Services.CreateScope())
{
var options = host.Services.GetService<IConfigurationService<ServicesConfiguration>>();
var optionsServer = host.Services.GetService<IConfigurationService<ServerConfiguration>>();
var logger = host.Services.GetService<ILogger<Program>>();
logger.LogInformation("Loaded LightlessSync Services Configuration (IsMain: {isMain})", options.IsMain);
logger.LogInformation(options.ToString());
logger.LogInformation("Loaded LightlessSync Server Configuration (IsMain: {isMain})", optionsServer.IsMain);
logger.LogInformation(optionsServer.ToString());
}
host.Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseSystemd()
.UseConsoleLifetime()
.ConfigureAppConfiguration((ctx, config) =>
{
var appSettingsPath = Environment.GetEnvironmentVariable("APPSETTINGS_PATH");
if (!string.IsNullOrEmpty(appSettingsPath))
{
config.AddJsonFile(appSettingsPath, optional: true, reloadOnChange: true);
}
else
{
config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
}
config.AddEnvironmentVariables();
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseContentRoot(AppContext.BaseDirectory);
webBuilder.ConfigureLogging((ctx, builder) =>
{
builder.AddConfiguration(ctx.Configuration.GetSection("Logging"));
builder.AddFile(o => o.RootPath = AppContext.BaseDirectory);
});
webBuilder.ConfigureKestrel((opt) =>
{
});
webBuilder.UseStartup<Startup>();
});
}

View File

@@ -0,0 +1,13 @@
{
"profiles": {
"LightlessSyncServices": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5294;https://localhost:7294",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,76 @@
using LightlessSyncServices.Discord;
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Services;
using StackExchange.Redis;
using LightlessSyncShared.Utils.Configuration;
namespace LightlessSyncServices;
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
var config = app.ApplicationServices.GetRequiredService<IConfigurationService<LightlessConfigurationBase>>();
var metricServer = new KestrelMetricServer(config.GetValueOrDefault<int>(nameof(LightlessConfigurationBase.MetricsPort), 4982));
metricServer.Start();
}
public void ConfigureServices(IServiceCollection services)
{
var lightlessConfig = Configuration.GetSection("LightlessSync");
services.AddDbContextPool<LightlessDbContext>(options =>
{
options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection"), builder =>
{
builder.MigrationsHistoryTable("_efmigrationshistory", "public");
}).UseSnakeCaseNamingConvention();
options.EnableThreadSafetyChecks(false);
}, Configuration.GetValue(nameof(LightlessConfigurationBase.DbContextPoolSize), 1024));
services.AddDbContextFactory<LightlessDbContext>(options =>
{
options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection"), builder =>
{
builder.MigrationsHistoryTable("_efmigrationshistory", "public");
builder.MigrationsAssembly("LightlessSyncShared");
}).UseSnakeCaseNamingConvention();
options.EnableThreadSafetyChecks(false);
});
services.AddSingleton(m => new LightlessMetrics(m.GetService<ILogger<LightlessMetrics>>(), new List<string> { },
new List<string> { }));
var redis = lightlessConfig.GetValue(nameof(ServerConfiguration.RedisConnectionString), string.Empty);
var options = ConfigurationOptions.Parse(redis);
options.ClientName = "Lightless";
options.ChannelPrefix = "UserData";
ConnectionMultiplexer connectionMultiplexer = ConnectionMultiplexer.Connect(options);
services.AddSingleton<IConnectionMultiplexer>(connectionMultiplexer);
services.Configure<ServicesConfiguration>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync"));
services.AddSingleton(Configuration);
services.AddSingleton<ServerTokenGenerator>();
services.AddSingleton<DiscordBotServices>();
services.AddHostedService<DiscordBot>();
services.AddSingleton<IConfigurationService<ServicesConfiguration>, LightlessConfigurationServiceServer<ServicesConfiguration>>();
services.AddSingleton<IConfigurationService<ServerConfiguration>, LightlessConfigurationServiceClient<ServerConfiguration>>();
services.AddSingleton<IConfigurationService<LightlessConfigurationBase>, LightlessConfigurationServiceClient<LightlessConfigurationBase>>();
services.AddHostedService(p => (LightlessConfigurationServiceClient<LightlessConfigurationBase>)p.GetService<IConfigurationService<LightlessConfigurationBase>>());
services.AddHostedService(p => (LightlessConfigurationServiceClient<ServerConfiguration>)p.GetService<IConfigurationService<ServerConfiguration>>());
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,29 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=;Username=;Password="
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Protocols": "Http2",
"Url": "http://+:5002"
}
}
},
"LightlessSync": {
"DbContextPoolSize": 1024,
"DiscordBotToken": "",
"DiscordChannelForMessages": "",
"PurgeUnusedAccounts": true,
"PurgeUnusedAccountsPeriodInDays": 14,
"FailedAuthForTempBan": 5,
"TempBanDurationInMinutes": 30
},
"AllowedHosts": "*"
}