diff --git a/Hammer/Commands/Infractions/InfractionCommand.Stats.cs b/Hammer/Commands/Infractions/InfractionCommand.Stats.cs index 15e3dbd..3e99997 100644 --- a/Hammer/Commands/Infractions/InfractionCommand.Stats.cs +++ b/Hammer/Commands/Infractions/InfractionCommand.Stats.cs @@ -3,7 +3,6 @@ using DSharpPlus.SlashCommands.Attributes; using Hammer.Configuration; using Hammer.Data; -using Humanizer; namespace Hammer.Commands.Infractions; @@ -13,73 +12,30 @@ internal sealed partial class InfractionCommand [SlashRequireGuild] public async Task StatsAsync(InteractionContext context) { - await context.DeferAsync().ConfigureAwait(false); - var embed = new DiscordEmbedBuilder(); - IReadOnlyList infractions = _infractionService.GetInfractions(context.Guild); if (infractions.Count == 0) { + var embed = new DiscordEmbedBuilder(); embed.WithColor(DiscordColor.Orange); embed.WithTitle("No infractions on record"); embed.WithDescription("Statistics cannot be generated because there are no infractions on record."); - } - else - { - if (_configurationService.TryGetGuildConfiguration(context.Guild, out GuildConfiguration? guildConfiguration)) - embed.WithColor(guildConfiguration.PrimaryColor); - else - embed.WithColor(DiscordColor.Purple); - - int totalInfractions = infractions.Count; - Infraction[] distinctUserInfractions = infractions.DistinctBy(i => i.UserId).ToArray(); - - int infractedUsers = distinctUserInfractions.Length; - int warnedUsers = distinctUserInfractions.Count(i => i.Type == InfractionType.Warning); - int mutedUsers = distinctUserInfractions.Count(i => i.Type is InfractionType.Mute or InfractionType.TemporaryMute); - int bannedUsers = distinctUserInfractions.Count(i => i.Type is InfractionType.Ban or InfractionType.TemporaryBan); - int kickedUsers = distinctUserInfractions.Count(i => i.Type is InfractionType.Kick); - int gaggedUsers = distinctUserInfractions.Count(i => i.Type is InfractionType.Gag); - - int warnings = infractions.Count(i => i.Type is InfractionType.Warning); - int gags = infractions.Count(i => i.Type is InfractionType.Gag); - int kicks = infractions.Count(i => i.Type is InfractionType.Kick); - int messagesDeleted = await _messageDeletionService.CountMessageDeletionsAsync(context.Guild); - - int tempMuteCount = infractions.Count(i => i.Type is InfractionType.TemporaryMute); - int muteCount = infractions.Count(i => i.Type is InfractionType.Mute); - var mutes = $"{muteCount + tempMuteCount} ({tempMuteCount}T / {muteCount}P)"; - int tempBanCount = infractions.Count(i => i.Type is InfractionType.TemporaryBan); - int banCount = infractions.Count(i => i.Type is InfractionType.Ban); - var bans = $"{banCount + tempBanCount} ({tempBanCount}T / {banCount}P)"; - - DateTimeOffset now = DateTimeOffset.UtcNow; - long remainingMuteDuration = _muteService.GetTemporaryMutes(context.Guild).Sum(m => (m.ExpiresAt!.Value - now).Ticks); - long remainingBanDuration = _banService.GetTemporaryBans(context.Guild).Sum(b => (b.ExpiresAt - now).Ticks); - - embed.WithTitle("Infraction Statistics"); - embed.AddField("Total Infractions", totalInfractions.ToString("N0"), true); - embed.AddField("Remaining Mute Duration", TimeSpan.FromTicks(remainingMuteDuration).Humanize(), true); - embed.AddField("Remaining Ban Duration", TimeSpan.FromTicks(remainingBanDuration).Humanize(), true); - - embed.AddField("Total Infracted Users", infractedUsers.ToString("N0"), true); - embed.AddField("Total Warned Users", warnedUsers.ToString("N0"), true); - embed.AddField("Total Muted Users", mutedUsers.ToString("N0"), true); - embed.AddField("Total Banned Users", bannedUsers.ToString("N0"), true); - embed.AddField("Total Kicked Users", kickedUsers.ToString("N0"), true); - embed.AddField("Total Gagged Users", gaggedUsers.ToString("N0"), true); + await context.CreateResponseAsync(embed, true).ConfigureAwait(false); + return; + } - embed.AddField("Warnings", warnings.ToString("N0"), true); - embed.AddField("Mutes", mutes, true); - embed.AddField("Bans", bans, true); - embed.AddField("Kicks", kicks.ToString("N0"), true); - embed.AddField("Gags", gags.ToString("N0"), true); - embed.AddField("Messages Deleted", messagesDeleted.ToString("N0"), true); + if (!_configurationService.TryGetGuildConfiguration(context.Guild, out GuildConfiguration? guildConfiguration)) + { + await context.CreateResponseAsync("Guild is not configured!", true).ConfigureAwait(false); + return; } + await context.DeferAsync().ConfigureAwait(false); + DiscordEmbed result = await _infractionStatisticsService.CreateStatisticsEmbedAsync(context.Guild).ConfigureAwait(false); + var builder = new DiscordWebhookBuilder(); - builder.AddEmbed(embed); + builder.AddEmbed(result); await context.EditResponseAsync(builder).ConfigureAwait(false); } } diff --git a/Hammer/Commands/Infractions/InfractionCommand.cs b/Hammer/Commands/Infractions/InfractionCommand.cs index e910077..b7acbb4 100644 --- a/Hammer/Commands/Infractions/InfractionCommand.cs +++ b/Hammer/Commands/Infractions/InfractionCommand.cs @@ -11,10 +11,8 @@ internal sealed partial class InfractionCommand : ApplicationCommandModule { private readonly ConfigurationService _configurationService; private readonly InfractionService _infractionService; + private readonly InfractionStatisticsService _infractionStatisticsService; private readonly DiscordLogService _logService; - private readonly BanService _banService; - private readonly MessageDeletionService _messageDeletionService; - private readonly MuteService _muteService; private readonly RuleService _ruleService; /// @@ -23,19 +21,15 @@ internal sealed partial class InfractionCommand : ApplicationCommandModule public InfractionCommand( ConfigurationService configurationService, DiscordLogService logService, - BanService banService, InfractionService infractionService, - MessageDeletionService messageDeletionService, - MuteService muteService, + InfractionStatisticsService infractionStatisticsService, RuleService ruleService ) { _configurationService = configurationService; _infractionService = infractionService; + _infractionStatisticsService = infractionStatisticsService; _logService = logService; - _banService = banService; - _messageDeletionService = messageDeletionService; - _muteService = muteService; _ruleService = ruleService; } } diff --git a/Hammer/Program.cs b/Hammer/Program.cs index 6fdc01a..eae89b3 100644 --- a/Hammer/Program.cs +++ b/Hammer/Program.cs @@ -33,6 +33,7 @@ await Host.CreateDefaultBuilder(args) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Hammer/Services/InfractionStatisticsService.cs b/Hammer/Services/InfractionStatisticsService.cs new file mode 100644 index 0000000..d8caf2c --- /dev/null +++ b/Hammer/Services/InfractionStatisticsService.cs @@ -0,0 +1,362 @@ +using System.Globalization; +using DSharpPlus.Entities; +using Hammer.Configuration; +using Hammer.Data; +using Hammer.Extensions; +using Humanizer; + +namespace Hammer.Services; + +/// +/// Represents a service which provides an API for fetching infraction statistics. +/// +internal sealed class InfractionStatisticsService +{ + private readonly ConfigurationService _configurationService; + private readonly BanService _banService; + private readonly InfractionService _infractionService; + private readonly MessageDeletionService _messageDeletionService; + private readonly MuteService _muteService; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration service. + /// The ban service. + /// The infraction service. + /// The message deletion service. + /// The mute service. + public InfractionStatisticsService(ConfigurationService configurationService, + BanService banService, + InfractionService infractionService, + MessageDeletionService messageDeletionService, + MuteService muteService) + { + _configurationService = configurationService; + _banService = banService; + _infractionService = infractionService; + _messageDeletionService = messageDeletionService; + _muteService = muteService; + } + + /// + /// Creates an embed which displays infraction statistics for the specified guild. + /// + /// The guild whose statistics to render. + /// A populated with the statistics of 's infractions. + /// is . + /// is not a configured guild. + public async Task CreateStatisticsEmbedAsync(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + + if (!_configurationService.TryGetGuildConfiguration(guild, out GuildConfiguration? guildConfiguration)) + throw new InvalidOperationException("Guild is not configured"); + + (int totalBanCount, int tempBanCount, int permBanCount) = GetTotalBanCount(guild); + (int totalMuteCount, int tempMuteCount, int permMuteCount) = GetTotalMuteCount(guild); + + int infractionCount = GetTotalInfractionCount(guild); + int totalKickCount = GetTotalKickCount(guild); + int totalGagCount = GetTotalGagCount(guild); + int totalWarningCount = GetTotalWarningCount(guild); + int usersBannedCount = GetDistinctBannedUsers(guild); + int usersKickedCount = GetDistinctKickedUsers(guild); + int usersMutedCount = GetDistinctMutedUsers(guild); + int usersGaggedCount = GetDistinctGaggedUsers(guild); + int usersWarnedCount = GetDistinctWarnedUsers(guild); + int totalMessagesDeletedCount = await GetTotalDeletedMessageCountAsync(guild).ConfigureAwait(false); + + (float A, float B) banRatio = Ratio(permBanCount, tempBanCount); + (float A, float B) muteRatio = Ratio(permMuteCount, tempMuteCount); + var banRatioFormatted = $"{permBanCount:N0} perm / {tempBanCount} temp ({banRatio.A:N} : {banRatio.B:N})"; + var muteRatioFormatted = $"{permMuteCount:N0} perm / {tempMuteCount} temp ({muteRatio.A:N} : {muteRatio.B:N})"; + + TimeSpan remainingBanTime = GetRemainingBanTime(guild); + TimeSpan remainingMuteTime = GetRemainingMuteTime(guild); + + DiscordEmbedBuilder embed = guild.CreateDefaultEmbed(guildConfiguration); + embed.WithTitle("Infraction Statistics"); + + embed.AddField("Total Infractions", $"{infractionCount:N0}", true); + embed.AddField("Bans", $"{totalBanCount:N0} ({usersBannedCount:N0} distinct)", true); + embed.AddField("Kicks", $"{totalKickCount:N0} ({usersKickedCount:N0} distinct)", true); + embed.AddField("Mutes", $"{totalMuteCount:N0} ({usersMutedCount:N0} distinct)", true); + embed.AddField("Gags", $"{totalGagCount:N0} ({usersGaggedCount:N0} distinct)", true); + embed.AddField("Warnings", $"{totalWarningCount:N0} ({usersWarnedCount:N0} distinct)", true); + embed.AddField("Messages Deleted", $"{totalMessagesDeletedCount:N0}", true); + embed.AddField("Ban Ratio", banRatioFormatted, true); + embed.AddField("Mute Ratio", muteRatioFormatted, true); + embed.AddField("Remaining Ban Time", remainingBanTime.Humanize(culture: CultureInfo.CurrentCulture), true); + embed.AddField("Remaining Mute Time", remainingMuteTime.Humanize(culture: CultureInfo.CurrentCulture), true); + + return embed.Build(); + } + + /// + /// Returns the total number of distinct users who have received a or a + /// . + /// + /// The guild whose bans to count. + /// The total number of users who have received at least one ban in . + /// is . + public int GetDistinctBannedUsers(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + IReadOnlyList bans = _infractionService.GetInfractions(guild, InfractionType.Ban); + IReadOnlyList temporaryBans = _infractionService.GetInfractions(guild, InfractionType.TemporaryBan); + var users = new HashSet(); + + for (var index = 0; index < bans.Count; index++) + users.Add(bans[index].UserId); + + for (var index = 0; index < temporaryBans.Count; index++) + users.Add(temporaryBans[index].UserId); + + return users.Count; + } + + /// + /// Returns the total number of distinct users who have received a . + /// + /// The guild whose gags to count. + /// The total number of users who have received at least one gag in . + /// is . + public int GetDistinctGaggedUsers(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + IReadOnlyList infractions = _infractionService.GetInfractions(guild, InfractionType.Gag); + var users = new HashSet(); + + for (var index = 0; index < infractions.Count; index++) + users.Add(infractions[index].UserId); + + return users.Count; + } + + /// + /// Returns the total number of distinct users who have received a . + /// + /// The guild whose kicks to count. + /// The total number of users who have received at least one kick in . + /// is . + public int GetDistinctKickedUsers(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + IReadOnlyList infractions = _infractionService.GetInfractions(guild, InfractionType.Kick); + var users = new HashSet(); + + for (var index = 0; index < infractions.Count; index++) + users.Add(infractions[index].UserId); + + return users.Count; + } + + /// + /// Returns the total number of distinct users who have received a or a + /// . + /// + /// The guild whose mutes to count. + /// The total number of users who have received at least one mute in . + /// is . + public int GetDistinctMutedUsers(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + IReadOnlyList mutes = _infractionService.GetInfractions(guild, InfractionType.Mute); + IReadOnlyList temporaryMutes = _infractionService.GetInfractions(guild, InfractionType.TemporaryMute); + var users = new HashSet(); + + for (var index = 0; index < mutes.Count; index++) + users.Add(mutes[index].UserId); + + for (var index = 0; index < temporaryMutes.Count; index++) + users.Add(temporaryMutes[index].UserId); + + return users.Count; + } + + /// + /// Returns the total number of distinct users who have received a . + /// + /// The guild whose warnings to count. + /// The total number of users who have received at least one warning in . + /// is . + public int GetDistinctWarnedUsers(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + IReadOnlyList infractions = _infractionService.GetInfractions(guild, InfractionType.Warning); + var users = new HashSet(); + + for (var index = 0; index < infractions.Count; index++) + users.Add(infractions[index].UserId); + + return users.Count; + } + + /// + /// Gets the remaining total ban duration for the specified guild. + /// + /// The guild whose temporary ban times to sum. + /// A representing the total remaining time of all temporary bans. + public TimeSpan GetRemainingBanTime(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + IReadOnlyList bans = _banService.GetTemporaryBans(guild); + TimeSpan total = TimeSpan.Zero; + + for (var index = 0; index < bans.Count; index++) + total += bans[index].ExpiresAt - DateTimeOffset.UtcNow; + + return total; + } + + /// + /// Gets the remaining total mute duration for the specified guild. + /// + /// The guild whose temporary mute times to sum. + /// A representing the total remaining time of all temporary mutes. + public TimeSpan GetRemainingMuteTime(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + IReadOnlyList mutes = _muteService.GetTemporaryMutes(guild); + TimeSpan total = TimeSpan.Zero; + + for (var index = 0; index < mutes.Count; index++) + { + if (mutes[index].ExpiresAt is { } expiresAt) + total += expiresAt - DateTimeOffset.UtcNow; + } + + return total; + } + + /// + /// Returns the total number of bans issued in the specified guild. + /// + /// The guild whose bans to count. + /// A tuple containing the total, the temporary, and the permanent, ban count. + /// is . + public (int Total, int Temporary, int Permanent) GetTotalBanCount(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + + int temporary = _infractionService.GetInfractions(guild, InfractionType.TemporaryBan).Count; + int permanent = _infractionService.GetInfractions(guild, InfractionType.Ban).Count; + + return (temporary + permanent, temporary, permanent); + } + + /// + /// Returns the total number of deleted messages in the specified guild. + /// + /// The guild whose deleted messages to count. + /// The total number of deleted messages in . + /// is . + public async Task GetTotalDeletedMessageCountAsync(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + return await _messageDeletionService.CountMessageDeletionsAsync(guild).ConfigureAwait(false); + } + + /// + /// Returns the total number of gags issued in the specified guild. + /// + /// The guild whose gags to count. + /// The total number of issued gags in . + /// is . + public int GetTotalGagCount(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + return _infractionService.GetInfractions(guild, InfractionType.Gag).Count; + } + + /// + /// Returns the total number of distinct users who have received an infraction of any kind. + /// + /// The guild whose infractions to count. + /// The total number of users who have received at least one infraction in . + /// is . + public int GetTotalDistinctUsers(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + IReadOnlyList infractions = _infractionService.GetInfractions(guild); + var users = new HashSet(); + + for (var index = 0; index < infractions.Count; index++) + users.Add(infractions[index].UserId); + + return users.Count; + } + + /// + /// Returns the total number of infractions issued in the specified guild. + /// + /// The guild whose infractions to count. + /// The total number of issued infractions in . + /// is . + public int GetTotalInfractionCount(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + return _infractionService.GetInfractions(guild).Count; + } + + /// + /// Returns the total number of gags issued in the specified guild. + /// + /// The guild whose gags to count. + /// The total number of issued gags in . + /// is . + public int GetTotalKickCount(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + return _infractionService.GetInfractions(guild, InfractionType.Kick).Count; + } + + /// + /// Returns the total number of mutes issued in the specified guild. + /// + /// The guild whose bans to count. + /// A tuple containing the total, the temporary, and the permanent, mute count. + /// is . + public (int Total, int Temporary, int Permanent) GetTotalMuteCount(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + + int temporary = _infractionService.GetInfractions(guild, InfractionType.TemporaryMute).Count; + int permanent = _infractionService.GetInfractions(guild, InfractionType.Mute).Count; + + return (temporary + permanent, temporary, permanent); + } + + /// + /// Returns the total number of warnings issued in the specified guild. + /// + /// The guild whose warnings to count. + /// The total number of issued warnings in . + /// is . + public int GetTotalWarningCount(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + return _infractionService.GetInfractions(guild, InfractionType.Warning).Count; + } + + private static (int A, int B) Ratio(int a, int b) + { + int gcd = Gcd(a, b); + return (a / gcd, b / gcd); + } + + private static int Gcd(int a, int b) + + { + while (a != 0 && b != 0) + { + if (a > b) + a %= b; + else + b %= a; + } + + return a == 0 ? b : a; + } +}