using Discord; using Discord.Interactions; using Discord.Rest; using Discord.WebSocket; using MareSynchronosShared.Data; using MareSynchronosShared.Models; using MareSynchronosShared.Services; using MareSynchronosShared.Utils.Configuration; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; namespace MareSynchronosServices.Discord; internal class DiscordBot : IHostedService { 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 readonly CancellationTokenSource? _processReportQueueCts; private CancellationTokenSource? _updateStatusCts; 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 }); _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(MareModule), _services).ConfigureAwait(false); await _interactionModule.AddModuleAsync(typeof(MareWizardModule), _services).ConfigureAwait(false); await _discordClient.LoginAsync(TokenType.Bot, token).ConfigureAwait(false); await _discordClient.StartAsync().ConfigureAwait(false); _discordClient.Ready += DiscordClient_Ready; _discordClient.InteractionCreated += async (x) => { var ctx = new SocketInteractionContext(_discordClient, x); await _interactionModule.ExecuteCommandAsync(ctx, _services).ConfigureAwait(false); }; await _botServices.Start().ConfigureAwait(false); } } public async Task StopAsync(CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(_configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty))) { await _botServices.Stop().ConfigureAwait(false); _processReportQueueCts?.Cancel(); _updateStatusCts?.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); _updateStatusCts?.Cancel(); _updateStatusCts?.Dispose(); _updateStatusCts = new(); _ = UpdateStatusAsync(_updateStatusCts.Token); await CreateOrUpdateModal(guild).ConfigureAwait(false); _botServices.UpdateGuild(guild); await _botServices.LogToChannel("Bot startup complete.").ConfigureAwait(false); _ = UpdateVanityRoles(guild, _updateStatusCts.Token); _ = RemoveUsersNotInVanityRole(_updateStatusCts.Token); } 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("Mare Services Bot Interaction Service"); eb.WithDescription("Press \"Start\" to interact with this bot!" + Environment.NewLine + Environment.NewLine + "You can handle all of your Mare account needs in this server through the easy to use interactive bot prompt. Just follow the instructions!"); eb.WithThumbnailUrl("https://raw.githubusercontent.com/Penumbra-Sync/repo/main/MareSynchronos/images/icon.png"); var cb = new ComponentBuilder(); cb.WithButton("Start", style: ButtonStyle.Primary, customId: "wizard-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 RemoveUsersNotInVanityRole(CancellationToken token) { var guild = (await _discordClient.Rest.GetGuildsAsync().ConfigureAwait(false)).First(); var appId = await _discordClient.GetApplicationInfoAsync().ConfigureAwait(false); 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, MareDbContext db, Group group, CancellationToken token) { var groupPrimaryUser = group.OwnerUID; var primaryUser = await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == group.OwnerUID).ConfigureAwait(false); if (primaryUser != null) { groupPrimaryUser = primaryUser.User.UID; } 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())}"); 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 allowedRoleIds, MareDbContext 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())}"); 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("Mare for " + onlineUsers + " Users")).ConfigureAwait(false); await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); } } }