using Discord; using Discord.Interactions; using Discord.Rest; using Discord.WebSocket; using MareSynchronos.API.Data.Enum; using MareSynchronos.API.Dto.User; using MareSynchronos.API.SignalR; using MareSynchronosServer.Hubs; using MareSynchronosShared.Data; using MareSynchronosShared.Services; using MareSynchronosShared.Utils.Configuration; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; using System.Text; 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 IHubContext _mareHubContext; private readonly IServiceProvider _services; private InteractionService _interactionModule; private CancellationTokenSource? _processReportQueueCts; private CancellationTokenSource? _updateStatusCts; private CancellationTokenSource? _vanityUpdateCts; public DiscordBot(DiscordBotServices botServices, IServiceProvider services, IConfigurationService configuration, IHubContext mareHubContext, ILogger logger, IConnectionMultiplexer connectionMultiplexer) { _botServices = botServices; _services = services; _configurationService = configuration; _mareHubContext = mareHubContext; _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)) { _interactionModule = new InteractionService(_discordClient); await _interactionModule.AddModuleAsync(typeof(MareModule), _services).ConfigureAwait(false); await _discordClient.LoginAsync(TokenType.Bot, token).ConfigureAwait(false); await _discordClient.StartAsync().ConfigureAwait(false); _discordClient.Ready += DiscordClient_Ready; _discordClient.ButtonExecuted += ButtonExecutedHandler; _discordClient.InteractionCreated += async (x) => { var ctx = new SocketInteractionContext(_discordClient, x); await _interactionModule.ExecuteCommandAsync(ctx, _services); }; await _botServices.Start(); _ = UpdateStatusAsync(); } } public async Task StopAsync(CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(_configurationService.GetValueOrDefault(nameof(ServicesConfiguration.DiscordBotToken), string.Empty))) { _discordClient.ButtonExecuted -= ButtonExecutedHandler; await _botServices.Stop(); _processReportQueueCts?.Cancel(); _updateStatusCts?.Cancel(); _vanityUpdateCts?.Cancel(); await _discordClient.LogoutAsync().ConfigureAwait(false); await _discordClient.StopAsync().ConfigureAwait(false); } } private async Task ButtonExecutedHandler(SocketMessageComponent arg) { var id = arg.Data.CustomId; if (!id.StartsWith("mare-report-button", StringComparison.Ordinal)) return; var userId = arg.User.Id; using var scope = _services.CreateScope(); using var dbContext = scope.ServiceProvider.GetRequiredService(); var user = await dbContext.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == userId).ConfigureAwait(false); if (user == null || (!user.User.IsModerator && !user.User.IsAdmin)) { EmbedBuilder eb = new(); eb.WithTitle($"Cannot resolve report"); eb.WithDescription($"<@{userId}>: You have no rights to resolve this report"); await arg.RespondAsync(embed: eb.Build()).ConfigureAwait(false); return; } id = id.Remove(0, "mare-report-button-".Length); var split = id.Split('-', StringSplitOptions.RemoveEmptyEntries); var profile = await dbContext.UserProfileData.SingleAsync(u => u.UserUID == split[1]).ConfigureAwait(false); var embed = arg.Message.Embeds.First(); var builder = embed.ToEmbedBuilder(); var otherPairs = await dbContext.ClientPairs.Where(p => p.UserUID == split[1]).Select(p => p.OtherUserUID).ToListAsync().ConfigureAwait(false); switch (split[0]) { case "dismiss": builder.AddField("Resolution", $"Dismissed by <@{userId}>"); builder.WithColor(Color.Green); profile.FlaggedForReport = false; break; case "banreporting": builder.AddField("Resolution", $"Dismissed by <@{userId}>, Reporting user banned"); builder.WithColor(Color.DarkGreen); profile.FlaggedForReport = false; var reportingUser = await dbContext.Auth.SingleAsync(u => u.UserUID == split[2]).ConfigureAwait(false); reportingUser.IsBanned = true; var regReporting = await dbContext.LodeStoneAuth.SingleAsync(u => u.User.UID == reportingUser.UserUID).ConfigureAwait(false); dbContext.BannedRegistrations.Add(new MareSynchronosShared.Models.BannedRegistrations() { DiscordIdOrLodestoneAuth = regReporting.HashedLodestoneId }); dbContext.BannedRegistrations.Add(new MareSynchronosShared.Models.BannedRegistrations() { DiscordIdOrLodestoneAuth = regReporting.DiscordId.ToString() }); break; case "banprofile": builder.AddField("Resolution", $"Profile has been banned by <@{userId}>"); builder.WithColor(Color.Red); profile.Base64ProfileImage = null; profile.UserDescription = null; profile.ProfileDisabled = true; profile.FlaggedForReport = false; break; case "banuser": builder.AddField("Resolution", $"User has been banned by <@{userId}>"); builder.WithColor(Color.DarkRed); var offendingUser = await dbContext.Auth.SingleAsync(u => u.UserUID == split[1]).ConfigureAwait(false); offendingUser.IsBanned = true; profile.Base64ProfileImage = null; profile.UserDescription = null; profile.ProfileDisabled = true; var reg = await dbContext.LodeStoneAuth.SingleAsync(u => u.User.UID == offendingUser.UserUID).ConfigureAwait(false); dbContext.BannedRegistrations.Add(new MareSynchronosShared.Models.BannedRegistrations() { DiscordIdOrLodestoneAuth = reg.HashedLodestoneId }); dbContext.BannedRegistrations.Add(new MareSynchronosShared.Models.BannedRegistrations() { DiscordIdOrLodestoneAuth = reg.DiscordId.ToString() }); break; } await dbContext.SaveChangesAsync().ConfigureAwait(false); await _mareHubContext.Clients.Users(otherPairs).SendAsync(nameof(IMareHub.Client_UserUpdateProfile), new UserDto(new(split[1]))).ConfigureAwait(false); await _mareHubContext.Clients.User(split[1]).SendAsync(nameof(IMareHub.Client_UserUpdateProfile), new UserDto(new(split[1]))).ConfigureAwait(false); await arg.Message.ModifyAsync(msg => { msg.Content = arg.Message.Content; msg.Components = null; msg.Embed = new Optional(builder.Build()); }).ConfigureAwait(false); } private async Task DiscordClient_Ready() { var guild = (await _discordClient.Rest.GetGuildsAsync()).First(); await _interactionModule.RegisterCommandsToGuildAsync(guild.Id, true).ConfigureAwait(false); //_ = RemoveUsersNotInVanityRole(); _ = ProcessReportsQueue(); } private Task Log(LogMessage msg) { _logger.LogInformation("{msg}", msg); return Task.CompletedTask; } private async Task ProcessReportsQueue() { var guild = (await _discordClient.Rest.GetGuildsAsync()).First(); _processReportQueueCts?.Cancel(); _processReportQueueCts?.Dispose(); _processReportQueueCts = new(); var token = _processReportQueueCts.Token; while (!token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(30)).ConfigureAwait(false); if (_discordClient.ConnectionState != ConnectionState.Connected) continue; var reportChannelId = _configurationService.GetValue(nameof(ServicesConfiguration.DiscordChannelForReports)); if (reportChannelId == null) continue; try { using (var scope = _services.CreateScope()) { _logger.LogTrace("Checking for Profile Reports"); var dbContext = scope.ServiceProvider.GetRequiredService(); if (!dbContext.UserProfileReports.Any()) { continue; } var reports = await dbContext.UserProfileReports.ToListAsync().ConfigureAwait(false); var restChannel = await guild.GetTextChannelAsync(reportChannelId.Value).ConfigureAwait(false); foreach (var report in reports) { var reportedUser = await dbContext.Users.SingleAsync(u => u.UID == report.ReportedUserUID).ConfigureAwait(false); var reportedUserLodestone = await dbContext.LodeStoneAuth.SingleOrDefaultAsync(u => u.User.UID == report.ReportedUserUID).ConfigureAwait(false); var reportingUser = await dbContext.Users.SingleAsync(u => u.UID == report.ReportingUserUID).ConfigureAwait(false); var reportingUserLodestone = await dbContext.LodeStoneAuth.SingleOrDefaultAsync(u => u.User.UID == report.ReportingUserUID).ConfigureAwait(false); var reportedUserProfile = await dbContext.UserProfileData.SingleAsync(u => u.UserUID == report.ReportedUserUID).ConfigureAwait(false); EmbedBuilder eb = new(); eb.WithTitle("Mare Synchronos Profile Report"); StringBuilder reportedUserSb = new(); StringBuilder reportingUserSb = new(); reportedUserSb.Append(reportedUser.UID); reportingUserSb.Append(reportingUser.UID); if (reportedUserLodestone != null) { reportedUserSb.AppendLine($" (<@{reportedUserLodestone.DiscordId}>)"); } if (reportingUserLodestone != null) { reportingUserSb.AppendLine($" (<@{reportingUserLodestone.DiscordId}>)"); } eb.AddField("Reported User", reportedUserSb.ToString()); eb.AddField("Reporting User", reportingUserSb.ToString()); eb.AddField("Report Date (UTC)", report.ReportDate); eb.AddField("Report Reason", string.IsNullOrWhiteSpace(report.ReportReason) ? "-" : report.ReportReason); eb.AddField("Reported User Profile Description", string.IsNullOrWhiteSpace(reportedUserProfile.UserDescription) ? "-" : reportedUserProfile.UserDescription); eb.AddField("Reported User Profile Is NSFW", reportedUserProfile.IsNSFW); var cb = new ComponentBuilder(); cb.WithButton("Dismiss Report", customId: $"mare-report-button-dismiss-{reportedUser.UID}", style: ButtonStyle.Primary); cb.WithButton("Ban profile", customId: $"mare-report-button-banprofile-{reportedUser.UID}", style: ButtonStyle.Secondary); cb.WithButton("Ban user", customId: $"mare-report-button-banuser-{reportedUser.UID}", style: ButtonStyle.Danger); cb.WithButton("Dismiss and Ban Reporting user", customId: $"mare-report-button-banreporting-{reportedUser.UID}-{reportingUser.UID}", style: ButtonStyle.Danger); if (!string.IsNullOrEmpty(reportedUserProfile.Base64ProfileImage)) { var fileName = reportedUser.UID + "_profile_" + Guid.NewGuid().ToString("N") + ".png"; eb.WithImageUrl($"attachment://{fileName}"); using MemoryStream ms = new(Convert.FromBase64String(reportedUserProfile.Base64ProfileImage)); await restChannel.SendFileAsync(ms, fileName, "User Report", embed: eb.Build(), components: cb.Build(), isSpoiler: true).ConfigureAwait(false); } else { var msg = await restChannel.SendMessageAsync(embed: eb.Build(), components: cb.Build()).ConfigureAwait(false); } dbContext.Remove(report); } await dbContext.SaveChangesAsync().ConfigureAwait(false); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to process reports"); } } } private async Task RemoveUsersNotInVanityRole() { _vanityUpdateCts?.Cancel(); _vanityUpdateCts?.Dispose(); _vanityUpdateCts = new(); var token = _vanityUpdateCts.Token; var guild = (await _discordClient.Rest.GetGuildsAsync()).First(); var commands = await guild.GetApplicationCommandsAsync(); var appId = await _discordClient.GetApplicationInfoAsync().ConfigureAwait(false); var vanityCommandId = commands.First(c => c.ApplicationId == appId.Id && c.Name == "setvanityuid").Id; while (!token.IsCancellationRequested) { try { _logger.LogInformation($"Cleaning up Vanity UIDs"); _logger.LogInformation("Getting application commands from guild {guildName}", guild.Name); var restGuild = await _discordClient.Rest.GetGuildAsync(guild.Id); var vanityCommand = await restGuild.GetSlashCommandAsync(vanityCommandId).ConfigureAwait(false); GuildApplicationCommandPermission commandPermissions = null; try { _logger.LogInformation($"Getting command permissions"); commandPermissions = await vanityCommand.GetCommandPermission().ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error getting command permissions"); throw new Exception("Can't get command permissions"); } _logger.LogInformation($"Getting allowed role ids from permissions"); List allowedRoleIds = new(); try { allowedRoleIds = (from perm in commandPermissions.Permissions where perm.TargetType == ApplicationCommandPermissionTarget.Role where perm.Permission select perm.TargetId).ToList(); } catch (Exception ex) { _logger.LogError(ex, "Error resolving permissions to roles"); } _logger.LogInformation($"Found allowed role ids: {string.Join(", ", allowedRoleIds)}"); if (allowedRoleIds.Any()) { await using var scope = _services.CreateAsyncScope(); await using (var db = scope.ServiceProvider.GetRequiredService()) { 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) { 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.Contains(u))) { _logger.LogInformation($"User {lodestoneAuth.User.UID} not in allowed roles, deleting alias"); 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().ConfigureAwait(false); await Task.Delay(1000); } foreach (var group in aliasedGroups) { var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(f => f.User.UID == group.OwnerUID).ConfigureAwait(false); RestGuildUser discordUser = null; if (lodestoneUser != null) { discordUser = await restGuild.GetUserAsync(lodestoneUser.DiscordId).ConfigureAwait(false); } _logger.LogInformation($"Checking Group: {group.GID}, owned by {lodestoneUser?.User?.UID ?? string.Empty} ({lodestoneUser?.User?.Alias ?? string.Empty}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List())}"); if (lodestoneUser == null || discordUser == null || !discordUser.RoleIds.Any(u => allowedRoleIds.Contains(u))) { _logger.LogInformation($"User {lodestoneUser.User.UID} not in allowed roles, deleting group alias"); group.Alias = null; db.Update(group); } await db.SaveChangesAsync().ConfigureAwait(false); await Task.Delay(1000); } } } 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), _vanityUpdateCts.Token).ConfigureAwait(false); } } private async Task UpdateStatusAsync() { _updateStatusCts = new(); while (!_updateStatusCts.IsCancellationRequested) { var endPoint = _connectionMultiplexer.GetEndPoints().First(); var onlineUsers = await _connectionMultiplexer.GetServer(endPoint).KeysAsync(pattern: "UID:*").CountAsync(); //_logger.LogInformation("Users online: " + onlineUsers); await _discordClient.SetActivityAsync(new Game("with " + onlineUsers + " Users")).ConfigureAwait(false); await Task.Delay(TimeSpan.FromSeconds(15)).ConfigureAwait(false); } } }