using System.Collections.Generic; using System; using System.Globalization; using System.Text; using System.Text.Json; using System.Linq; using Discord; using Discord.Interactions; using Discord.Rest; using Discord.WebSocket; using LightlessSyncShared.Data; using LightlessSyncShared.Models; using LightlessSyncShared.Services; using LightlessSync.API.Dto.Chat; using LightlessSyncShared.Utils.Configuration; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; namespace LightlessSyncServices.Discord; internal class DiscordBot : IHostedService { private static readonly JsonSerializerOptions ChatReportSerializerOptions = new(JsonSerializerDefaults.General) { PropertyNameCaseInsensitive = true }; private const string ChatReportButtonPrefix = "lightless-chat-report-button"; private readonly DiscordBotServices _botServices; private readonly IConfigurationService _configurationService; private readonly IConnectionMultiplexer _connectionMultiplexer; private readonly DiscordSocketClient _discordClient; private readonly ILogger _logger; private readonly IDbContextFactory _dbContextFactory; private readonly IServiceProvider _services; private InteractionService _interactionModule; private CancellationTokenSource? _chatReportProcessingCts; private CancellationTokenSource? _clientConnectedCts; public DiscordBot(DiscordBotServices botServices, IServiceProvider services, IConfigurationService configuration, IDbContextFactory dbContextFactory, ILogger 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.ButtonExecuted += OnChatReportButton; _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); _chatReportProcessingCts?.Cancel(); _chatReportProcessingCts?.Dispose(); _clientConnectedCts?.Cancel(); _discordClient.ButtonExecuted -= OnChatReportButton; 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); _chatReportProcessingCts?.Cancel(); _chatReportProcessingCts?.Dispose(); _chatReportProcessingCts = new(); _ = PollChatReportsAsync(_chatReportProcessingCts.Token); await PublishChatReportsAsync(CancellationToken.None).ConfigureAwait(false); 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 PollChatReportsAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { await PublishChatReportsAsync(token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogWarning(ex, "Failed while polling chat reports"); } try { await Task.Delay(TimeSpan.FromMinutes(10), token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } private async Task PublishChatReportsAsync(CancellationToken token) { var reportChannelId = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForChatReports), (ulong?)null); if (reportChannelId is null) { return; } var channel = await _discordClient.Rest.GetChannelAsync(reportChannelId.Value).ConfigureAwait(false) as RestTextChannel; if (channel is null) { _logger.LogWarning("Configured chat report channel {ChannelId} could not be resolved.", reportChannelId); return; } using var dbContext = await _dbContextFactory.CreateDbContextAsync(token).ConfigureAwait(false); var pendingReports = await dbContext.ReportedChatMessages .Where(r => !r.Resolved && r.DiscordMessageId == null) .OrderBy(r => r.ReportTimeUtc) .Take(10) .ToListAsync(token) .ConfigureAwait(false); if (pendingReports.Count == 0) { return; } foreach (var report in pendingReports) { var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false); var components = new ComponentBuilder() .WithButton("Actioned", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger) .WithButton("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary) .WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger); var postedMessage = await channel.SendMessageAsync(embed: embed.Build(), components: components.Build()).ConfigureAwait(false); report.DiscordMessageId = postedMessage.Id; report.DiscordMessagePostedAtUtc = DateTime.UtcNow; } await dbContext.SaveChangesAsync(token).ConfigureAwait(false); } private async Task BuildChatReportEmbedAsync(LightlessDbContext dbContext, ReportedChatMessage report, CancellationToken token) { var reporter = await FormatUserForEmbedAsync(dbContext, report.ReporterUserUid, token).ConfigureAwait(false); var reportedUser = await FormatUserForEmbedAsync(dbContext, report.ReportedUserUid, token).ConfigureAwait(false); var channelDescription = await DescribeChannelAsync(dbContext, report, token).ConfigureAwait(false); var embed = new EmbedBuilder() .WithTitle("Chat Report") .WithColor(Color.DarkTeal) .WithTimestamp(report.ReportTimeUtc) .AddField("Report ID", report.ReportId, inline: true) .AddField("Reporter", reporter, inline: true) .AddField("Reported User", string.IsNullOrEmpty(reportedUser) ? "-" : reportedUser, inline: true) .AddField("Channel", channelDescription, inline: false) .AddField("Reason", string.IsNullOrWhiteSpace(report.Reason) ? "-" : report.Reason); if (!string.IsNullOrWhiteSpace(report.AdditionalContext)) { embed.AddField("Additional Context", report.AdditionalContext); } embed.AddField("Message", $"```{Truncate(report.MessageContent, 1000)}```"); var snapshotPreview = BuildSnapshotPreview(report.SnapshotJson); if (!string.IsNullOrEmpty(snapshotPreview)) { embed.AddField("Recent Activity", snapshotPreview); } embed.WithFooter($"Message ID: {report.MessageId}"); return embed; } private async Task DescribeChannelAsync(LightlessDbContext dbContext, ReportedChatMessage report, CancellationToken token) { if (report.ChannelType == ChatChannelType.Group) { if (!string.IsNullOrEmpty(report.ChannelKey)) { var group = await dbContext.Groups.AsNoTracking() .SingleOrDefaultAsync(g => g.GID == report.ChannelKey, token) .ConfigureAwait(false); if (group != null) { var name = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; return $"Group: {name} ({group.GID})"; } } return $"Group: {report.ChannelKey ?? "unknown"}"; } return $"Zone: {report.ChannelKey ?? "unknown"} (World {report.WorldId}, Zone {report.ZoneId})"; } private async Task FormatUserForEmbedAsync(LightlessDbContext dbContext, string? userUid, CancellationToken token) { if (string.IsNullOrEmpty(userUid)) { return "-"; } var user = await dbContext.Users.AsNoTracking() .SingleOrDefaultAsync(u => u.UID == userUid, token) .ConfigureAwait(false); var display = user?.Alias ?? user?.UID ?? userUid; var lodestone = await dbContext.LodeStoneAuth .Include(l => l.User) .AsNoTracking() .SingleOrDefaultAsync(l => l.User != null && l.User.UID == userUid, token) .ConfigureAwait(false); if (lodestone != null) { display = $"{display} (<@{lodestone.DiscordId}>)"; } return display; } private string BuildSnapshotPreview(string snapshotJson) { if (string.IsNullOrWhiteSpace(snapshotJson)) { return string.Empty; } try { var snapshot = JsonSerializer.Deserialize>(snapshotJson, ChatReportSerializerOptions); if (snapshot is null || snapshot.Count == 0) { return string.Empty; } var builder = new StringBuilder(); foreach (var item in snapshot.TakeLast(5)) { var sender = item.SenderAlias ?? item.SenderUserUid; builder.AppendLine($"{item.SentAtUtc:HH\\:mm} {sender}: {Truncate(item.Message, 120)}"); } return $"```{builder.ToString().TrimEnd()}```"; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse chat report snapshot"); return string.Empty; } } private static string Truncate(string value, int maxLength) { if (string.IsNullOrEmpty(value) || value.Length <= maxLength) { return value; } return value[..maxLength] + "..."; } private async Task OnChatReportButton(SocketMessageComponent arg) { if (!arg.Data.CustomId.StartsWith(ChatReportButtonPrefix, StringComparison.Ordinal)) { return; } if (arg.GuildId is null) { await arg.RespondAsync("This action is only available inside the server.", ephemeral: true).ConfigureAwait(false); return; } var guild = _discordClient.GetGuild(arg.GuildId.Value); if (guild is null) { await arg.RespondAsync("Unable to resolve the guild for this interaction.", ephemeral: true).ConfigureAwait(false); return; } var guildUser = guild.GetUser(arg.User.Id); if (guildUser is null || !(guildUser.GuildPermissions.ManageMessages || guildUser.GuildPermissions.BanMembers || guildUser.GuildPermissions.Administrator)) { await arg.RespondAsync("You do not have permission to resolve chat reports.", ephemeral: true).ConfigureAwait(false); return; } var parts = arg.Data.CustomId.Split('-', StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 5 || !int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var reportId)) { await arg.RespondAsync("Invalid report action.", ephemeral: true).ConfigureAwait(false); return; } var action = parts[^2]; await using var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); var report = await dbContext.ReportedChatMessages.SingleOrDefaultAsync(r => r.ReportId == reportId).ConfigureAwait(false); if (report is null) { await arg.RespondAsync("This report could not be found.", ephemeral: true).ConfigureAwait(false); return; } if (report.Resolved) { await arg.RespondAsync("This report has already been processed.", ephemeral: true).ConfigureAwait(false); return; } string resolutionLabel; switch (action) { case "resolve": resolutionLabel = "Actioned"; break; case "dismiss": resolutionLabel = "Dismissed"; break; case "banchat": resolutionLabel = "Chat access revoked"; if (!string.IsNullOrEmpty(report.ReportedUserUid)) { var targetUser = await dbContext.Users.SingleOrDefaultAsync(u => u.UID == report.ReportedUserUid).ConfigureAwait(false); if (targetUser is not null && !targetUser.ChatBanned) { targetUser.ChatBanned = true; dbContext.Update(targetUser); } } break; default: await arg.RespondAsync("Unknown action.", ephemeral: true).ConfigureAwait(false); return; } try { await UpdateChatReportMessageAsync(report, action, guildUser).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to update Discord message for resolved report {ReportId}", report.ReportId); } dbContext.ReportedChatMessages.Remove(report); await dbContext.SaveChangesAsync().ConfigureAwait(false); string responseText = action switch { "resolve" => "actioned", "dismiss" => "dismissed", "banchat" => "chat access revoked", _ => "processed" }; await arg.RespondAsync($"Report {report.ReportId} {responseText}.", ephemeral: true).ConfigureAwait(false); } private async Task UpdateChatReportMessageAsync(ReportedChatMessage report, string action, SocketGuildUser moderator) { if (report.DiscordMessageId is null) { return; } var reportChannelId = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForChatReports), (ulong?)null); if (reportChannelId is null) { return; } var channel = await _discordClient.Rest.GetChannelAsync(reportChannelId.Value).ConfigureAwait(false) as RestTextChannel; if (channel is null) { return; } var message = await channel.GetMessageAsync(report.DiscordMessageId.Value).ConfigureAwait(false) as IUserMessage; if (message is null) { return; } var existingEmbed = message.Embeds.FirstOrDefault(); var embedBuilder = existingEmbed is Embed richEmbed ? richEmbed.ToEmbedBuilder() : new EmbedBuilder().WithTitle("Chat Report"); embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase)); var resolutionText = action switch { "resolve" => "Actioned", "dismiss" => "Dismissed", "banchat" => "Chat access revoked", _ => "Processed" }; var resolutionColor = action switch { "resolve" => Color.DarkRed, "dismiss" => Color.Green, "banchat" => Color.DarkRed, _ => Color.LightGrey }; embedBuilder.AddField("Resolution", $"{resolutionText} by {moderator.Mention} at "); embedBuilder.WithColor(resolutionColor); await message.ModifyAsync(props => { props.Embed = embedBuilder.Build(); props.Components = new ComponentBuilder().Build(); }).ConfigureAwait(false); } private async Task UpdateVanityRoles(RestGuild guild, CancellationToken token) { while (!token.IsCancellationRequested) { try { _logger.LogInformation("Updating Vanity Roles"); Dictionary vanityRoles = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.VanityRoles), new Dictionary()); 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(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(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; 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 allowedRoleIds = _configurationService.GetValueOrDefault(nameof(ServicesConfiguration.VanityRoles), new Dictionary()); _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 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())}"); var hasAllowedRole = lodestoneUser != null && discordUser != null && discordUser.RoleIds.Any(allowedRoleIds.Keys.Contains); if (!hasAllowedRole) { 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); if (lodestoneUser?.User != null) { lodestoneUser.User.HasVanity = false; db.Update(lodestoneUser.User); var secondaryUsers = await db.Auth.Include(u => u.User) .Where(u => u.PrimaryUserUID == lodestoneUser.User.UID).ToListAsync().ConfigureAwait(false); foreach (var secondaryUser in secondaryUsers) { secondaryUser.User.HasVanity = false; db.Update(secondaryUser.User); } } await db.SaveChangesAsync(token).ConfigureAwait(false); } else if (lodestoneUser?.User != null && !lodestoneUser.User.HasVanity) { lodestoneUser.User.HasVanity = true; db.Update(lodestoneUser.User); var secondaryUsers = await db.Auth.Include(u => u.User) .Where(u => u.PrimaryUserUID == lodestoneUser.User.UID).ToListAsync().ConfigureAwait(false); foreach (var secondaryUser in secondaryUsers) { if (!secondaryUser.User.HasVanity) { secondaryUser.User.HasVanity = true; db.Update(secondaryUser.User); } } await db.SaveChangesAsync(token).ConfigureAwait(false); } } private async Task CheckVanityForUser(RestGuild restGuild, Dictionary 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())}"); var hasAllowedRole = discordUser != null && discordUser.RoleIds.Any(u => allowedRoleIds.Keys.Contains(u)); if (!hasAllowedRole) { _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; lodestoneAuth.User.HasVanity = false; 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; secondaryUser.User.HasVanity = false; db.Update(secondaryUser.User); } db.Update(lodestoneAuth.User); await db.SaveChangesAsync(token).ConfigureAwait(false); } else { var secondaryUsers = await db.Auth.Include(u => u.User) .Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().ConfigureAwait(false); var hasChanges = false; if (!lodestoneAuth.User.HasVanity) { lodestoneAuth.User.HasVanity = true; db.Update(lodestoneAuth.User); hasChanges = true; } foreach (var secondaryUser in secondaryUsers) { if (!secondaryUser.User.HasVanity) { secondaryUser.User.HasVanity = true; db.Update(secondaryUser.User); hasChanges = true; } } if (hasChanges) { await db.SaveChangesAsync(token).ConfigureAwait(false); } } } private sealed record ChatReportSnapshotItem( string MessageId, DateTime SentAtUtc, string SenderUserUid, string? SenderAlias, bool SenderIsLightfinder, string? SenderHashedCid, string Message); 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); } } }