diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBot.cs b/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBot.cs index 48da008..3ed1dc7 100644 --- a/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBot.cs +++ b/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBot.cs @@ -22,7 +22,7 @@ internal class DiscordBot : IHostedService private readonly IServiceProvider _services; private InteractionService _interactionModule; private readonly CancellationTokenSource? _processReportQueueCts; - private CancellationTokenSource? _updateStatusCts; + private CancellationTokenSource? _clientConnectedCts; public DiscordBot(DiscordBotServices botServices, IServiceProvider services, IConfigurationService configuration, IDbContextFactory dbContextFactory, @@ -36,7 +36,8 @@ internal class DiscordBot : IHostedService _connectionMultiplexer = connectionMultiplexer; _discordClient = new(new DiscordSocketConfig() { - DefaultRetryMode = RetryMode.AlwaysRetry + DefaultRetryMode = RetryMode.AlwaysRetry, + GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.GuildMembers }); _discordClient.Log += Log; @@ -76,7 +77,7 @@ internal class DiscordBot : IHostedService { await _botServices.Stop().ConfigureAwait(false); _processReportQueueCts?.Cancel(); - _updateStatusCts?.Cancel(); + _clientConnectedCts?.Cancel(); await _discordClient.LogoutAsync().ConfigureAwait(false); await _discordClient.StopAsync().ConfigureAwait(false); @@ -88,16 +89,17 @@ internal class DiscordBot : IHostedService { var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First(); await _interactionModule.RegisterCommandsToGuildAsync(guild.Id, true).ConfigureAwait(false); - _updateStatusCts?.Cancel(); - _updateStatusCts?.Dispose(); - _updateStatusCts = new(); - _ = UpdateStatusAsync(_updateStatusCts.Token); + _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, _updateStatusCts.Token); - _ = RemoveUsersNotInVanityRole(_updateStatusCts.Token); + _ = UpdateVanityRoles(guild, _clientConnectedCts.Token); + _ = RemoveUsersNotInVanityRole(_clientConnectedCts.Token); + _ = RemoveUnregisteredUsers(_clientConnectedCts.Token); } private async Task UpdateVanityRoles(RestGuild guild, CancellationToken token) @@ -207,10 +209,66 @@ internal class DiscordBot : IHostedService 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 MareDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(token).ConfigureAwait(false); + var roleId = _configurationService.GetValueOrDefault(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(await dbContext.LodeStoneAuth.AsNoTracking().Select(c => c.DiscordId).ToListAsync().ConfigureAwait(false)); + + var executionStartTime = DateTimeOffset.UtcNow; + + 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", userList.Count); + foreach (var user in userList) + { + if (registeredUsers.Contains(user.Id)) + await _botServices.AddRegisteredRoleAsync(user, registrationRole).ConfigureAwait(false); + + if (kickUnregistered) + { + if ((executionStartTime - user.JoinedAt.Value).TotalDays > 7) + await _botServices.KickUserAsync(user).ConfigureAwait(false); + } + + token.ThrowIfCancellationRequested(); + } + } + + await _botServices.LogToChannel("Processing registered users finished").ConfigureAwait(false); + } + private async Task RemoveUsersNotInVanityRole(CancellationToken token) { var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First(); - var appId = await _discordClient.GetApplicationInfoAsync().ConfigureAwait(false); while (!token.IsCancellationRequested) { diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBotServices.cs b/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBotServices.cs index cdef26f..f2e460f 100644 --- a/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBotServices.cs +++ b/MareSynchronosServer/MareSynchronosServices/Discord/DiscordBotServices.cs @@ -1,8 +1,13 @@ using System.Collections.Concurrent; +using Discord; +using Discord.Net; using Discord.Rest; +using Discord.WebSocket; using MareSynchronosShared.Metrics; +using MareSynchronosShared.Models; using MareSynchronosShared.Services; using MareSynchronosShared.Utils.Configuration; +using StackExchange.Redis; namespace MareSynchronosServices.Discord; @@ -51,6 +56,7 @@ public class DiscordBotServices public async Task LogToChannel(string msg) { if (_guild == null) return; + Logger.LogInformation("LogToChannel: {msg}", msg); var logChannelId = _configuration.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForBotLog), null); if (logChannelId == null) return; if (logChannelId != _logChannelId) @@ -67,10 +73,74 @@ public class DiscordBotServices } 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(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(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 AddRegisteredRoleAsync(RestGuildUser user, RestRole role) + { + if (user.RoleIds.Contains(role.Id)) return; + await RetryAsync(user.AddRoleAsync(role), user, $"Add Registered Role", false).ConfigureAwait(false); + } + + public async Task KickUserAsync(RestGuildUser user) + { + await RetryAsync(user.KickAsync("No registration found"), user, "Kick").ConfigureAwait(false); + } + private async Task ProcessVerificationQueue() { while (!verificationTaskCts.IsCancellationRequested) diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Delete.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Delete.cs index 8259ed2..a51b6b3 100644 --- a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Delete.cs +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Delete.cs @@ -2,6 +2,7 @@ using Discord; using MareSynchronosShared.Utils; using MareSynchronosShared.Utils.Configuration; +using Discord.WebSocket; namespace MareSynchronosServices.Discord; @@ -100,6 +101,8 @@ public partial class MareWizardModule await ModifyModalInteraction(eb, cb).ConfigureAwait(false); await _botServices.LogToChannel($"{Context.User.Mention} DELETE SUCCESS: {uid}").ConfigureAwait(false); + + await _botServices.RemoveRegisteredRoleAsync(Context.Interaction.User).ConfigureAwait(false); } } catch (Exception ex) diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Register.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Register.cs index 044da3b..7e6f2ac 100644 --- a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Register.cs +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Register.cs @@ -4,6 +4,10 @@ using MareSynchronosShared.Data; using Microsoft.EntityFrameworkCore; using MareSynchronosShared.Utils; using MareSynchronosShared.Models; +using MareSynchronosShared.Services; +using MareSynchronosShared.Utils.Configuration; +using Discord.Rest; +using Discord.WebSocket; namespace MareSynchronosServices.Discord; @@ -140,20 +144,22 @@ public partial class MareWizardModule + Environment.NewLine + "Have fun."); AddHome(cb); + + await _botServices.AddRegisteredRoleAsync(Context.Interaction.User).ConfigureAwait(false); } 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." + 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." + + "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\".**" + + "**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:" + + "## 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("❌")); diff --git a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Relink.cs b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Relink.cs index bad84e3..ef3c6e8 100644 --- a/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Relink.cs +++ b/MareSynchronosServer/MareSynchronosServices/Discord/MareWizardModule.Relink.cs @@ -129,6 +129,8 @@ public partial class MareWizardModule + Environment.NewLine + "Have fun."); AddHome(cb); + + await _botServices.AddRegisteredRoleAsync(Context.Interaction.User).ConfigureAwait(false); } else { diff --git a/MareSynchronosServer/MareSynchronosShared/Utils/Configuration/ServicesConfiguration.cs b/MareSynchronosServer/MareSynchronosShared/Utils/Configuration/ServicesConfiguration.cs index 9a0bce4..78e7c28 100644 --- a/MareSynchronosServer/MareSynchronosShared/Utils/Configuration/ServicesConfiguration.cs +++ b/MareSynchronosServer/MareSynchronosShared/Utils/Configuration/ServicesConfiguration.cs @@ -9,6 +9,8 @@ public class ServicesConfiguration : MareConfigurationBase public ulong? DiscordChannelForCommands { get; set; } = null; public ulong? DiscordRoleAprilFools2024 { get; set; } = null; public ulong? DiscordChannelForBotLog { get; set; } = null!; + public ulong? DiscordRoleRegistered { get; set; } = null!; + public bool KickNonRegisteredUsers { get; set; } = false; public Uri MainServerAddress { get; set; } = null; public Dictionary VanityRoles { get; set; } = new Dictionary(); @@ -21,6 +23,8 @@ public class ServicesConfiguration : MareConfigurationBase sb.AppendLine($"{nameof(DiscordChannelForMessages)} => {DiscordChannelForMessages}"); sb.AppendLine($"{nameof(DiscordChannelForCommands)} => {DiscordChannelForCommands}"); sb.AppendLine($"{nameof(DiscordRoleAprilFools2024)} => {DiscordRoleAprilFools2024}"); + sb.AppendLine($"{nameof(DiscordRoleRegistered)} => {DiscordRoleRegistered}"); + sb.AppendLine($"{nameof(KickNonRegisteredUsers)} => {KickNonRegisteredUsers}"); foreach (var role in VanityRoles) { sb.AppendLine($"{nameof(VanityRoles)} => {role.Key} = {role.Value}");