diff --git a/Clients/CirrusCiClient/CirrusCi.cs b/Clients/CirrusCiClient/CirrusCi.cs index 01aa7bea..2d2fec27 100644 --- a/Clients/CirrusCiClient/CirrusCi.cs +++ b/Clients/CirrusCiClient/CirrusCi.cs @@ -12,141 +12,140 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using StrawberryShake; -namespace CirrusCiClient +namespace CirrusCiClient; + +public static class CirrusCi { - public static class CirrusCi + private static readonly MemoryCache BuildInfoCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromHours(1) }); + private static readonly IServiceProvider ServiceProvider; + private static IClient Client => ServiceProvider.GetRequiredService(); + + static CirrusCi() { - private static readonly MemoryCache BuildInfoCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromHours(1) }); - private static readonly IServiceProvider ServiceProvider; - private static IClient Client => ServiceProvider.GetRequiredService(); + var collection = new ServiceCollection(); + collection.AddClient(ExecutionStrategy.CacheAndNetwork) + .ConfigureHttpClient(c => c.BaseAddress = new("https://api.cirrus-ci.com/graphql")); + ServiceProvider = collection.BuildServiceProvider(); + } - static CirrusCi() - { - var collection = new ServiceCollection(); - collection.AddClient(ExecutionStrategy.CacheAndNetwork) - .ConfigureHttpClient(c => c.BaseAddress = new("https://api.cirrus-ci.com/graphql")); - ServiceProvider = collection.BuildServiceProvider(); - } + public static async Task GetPrBuildInfoAsync(string? commit, DateTime? oldestTimestamp, int pr, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(commit)) + return null; - public static async Task GetPrBuildInfoAsync(string? commit, DateTime? oldestTimestamp, int pr, CancellationToken cancellationToken) + commit = commit.ToLower(); + var queryResult = await Client.GetPrBuilds.ExecuteAsync("pull/" + pr, oldestTimestamp.ToTimestamp(), cancellationToken); + queryResult.EnsureNoErrors(); + if (queryResult.Data?.OwnerRepository?.Builds?.Edges is {Count: > 0} edgeList) { - if (string.IsNullOrEmpty(commit)) + var node = edgeList.LastOrDefault(e => e?.Node?.ChangeIdInRepo == commit)?.Node; + if (node is null) return null; - commit = commit.ToLower(); - var queryResult = await Client.GetPrBuilds.ExecuteAsync("pull/" + pr, oldestTimestamp.ToTimestamp(), cancellationToken); - queryResult.EnsureNoErrors(); - if (queryResult.Data?.OwnerRepository?.Builds?.Edges is {Count: > 0} edgeList) + var winTask = node.Tasks?.FirstOrDefault(t => t?.Name.Contains("Windows") ?? false); + var winArtifact = winTask?.Artifacts? + .Where(a => a?.Files is {Count: >0}) + .SelectMany(a => a!.Files!) + .FirstOrDefault(f => f?.Path.EndsWith(".7z") ?? false); + + var linTask = node.Tasks?.FirstOrDefault(t => t is {} lt && lt.Name.Contains("Linux") && lt.Name.Contains("GCC")); + var linArtifact = linTask?.Artifacts? + .Where(a => a?.Files is {Count: >0}) + .SelectMany(a => a!.Files!) + .FirstOrDefault(a => a?.Path.EndsWith(".AppImage") ?? false); + + var macTask = node.Tasks?.FirstOrDefault(t => t?.Name.Contains("macOS") ?? false); + var macArtifact = macTask?.Artifacts? + .Where(a => a?.Files is { Count: > 0 }) + .SelectMany(a => a!.Files!) + .FirstOrDefault(a => a?.Path.EndsWith(".dmg") ?? false); + + var startTime = FromTimestamp(node.BuildCreatedTimestamp); + var finishTime = GetFinishTime(node); + return new() { - var node = edgeList.LastOrDefault(e => e?.Node?.ChangeIdInRepo == commit)?.Node; - if (node is null) - return null; + Commit = node.ChangeIdInRepo, + StartTime = startTime, + FinishTime = finishTime, - var winTask = node.Tasks?.FirstOrDefault(t => t?.Name.Contains("Windows") ?? false); - var winArtifact = winTask?.Artifacts? - .Where(a => a?.Files is {Count: >0}) - .SelectMany(a => a!.Files!) - .FirstOrDefault(f => f?.Path.EndsWith(".7z") ?? false); - - var linTask = node.Tasks?.FirstOrDefault(t => t is {} lt && lt.Name.Contains("Linux") && lt.Name.Contains("GCC")); - var linArtifact = linTask?.Artifacts? - .Where(a => a?.Files is {Count: >0}) - .SelectMany(a => a!.Files!) - .FirstOrDefault(a => a?.Path.EndsWith(".AppImage") ?? false); - - var macTask = node.Tasks?.FirstOrDefault(t => t?.Name.Contains("macOS") ?? false); - var macArtifact = macTask?.Artifacts? - .Where(a => a?.Files is { Count: > 0 }) - .SelectMany(a => a!.Files!) - .FirstOrDefault(a => a?.Path.EndsWith(".dmg") ?? false); - - var startTime = FromTimestamp(node.BuildCreatedTimestamp); - var finishTime = GetFinishTime(node); - return new() + WindowsBuild = new() { - Commit = node.ChangeIdInRepo, - StartTime = startTime, - FinishTime = finishTime, - - WindowsBuild = new() - { - Filename = winArtifact?.Path is string wp ? Path.GetFileName(wp) : null, - DownloadLink = winTask?.Id is string wtid && winArtifact?.Path is string wtap ? $"https://api.cirrus-ci.com/v1/artifact/task/{wtid}/Artifact/{wtap}" : null, - Status = winTask?.Status, - }, - LinuxBuild = new() - { - Filename = linArtifact?.Path is string lp ? Path.GetFileName(lp) : null, - DownloadLink = linTask?.Id is string ltid && linArtifact?.Path is string ltap ? $"https://api.cirrus-ci.com/v1/artifact/task/{ltid}/Artifact/{ltap}" : null, - Status = linTask?.Status, - }, - MacBuild = new() - { - Filename = macArtifact?.Path is string mp ? Path.GetFileName(mp) : null, - DownloadLink = macTask?.Id is string mtid && macArtifact?.Path is string mtap ? $"https://api.cirrus-ci.com/v1/artifact/task/{mtid}/Artifact/{mtap}" : null, - Status= macTask?.Status, - } - }; - } - return null; - } - - public static async Task GetPipelineDurationAsync(CancellationToken cancellationToken) - { - const string cacheKey = "project-build-stats"; - if (BuildInfoCache.TryGetValue(cacheKey, out ProjectBuildStats result)) - return result; - - try - { - var queryResult = await Client.GetLastFewBuilds.ExecuteAsync(200, cancellationToken).ConfigureAwait(false); - queryResult.EnsureNoErrors(); - - var times = ( - from edge in queryResult.Data?.OwnerRepository?.Builds?.Edges - let node = edge?.Node - where node?.Status == BuildStatus.Completed - let p = new {start = FromTimestamp(node.BuildCreatedTimestamp), finish = GetFinishTime(node)} - where p.finish.HasValue - let ts = p.finish!.Value - p.start - orderby ts descending - select ts - ).ToList(); - if (times.Count <= 10) - return ProjectBuildStats.Defaults; - - result = new() + Filename = winArtifact?.Path is string wp ? Path.GetFileName(wp) : null, + DownloadLink = winTask?.Id is string wtid && winArtifact?.Path is string wtap ? $"https://api.cirrus-ci.com/v1/artifact/task/{wtid}/Artifact/{wtap}" : null, + Status = winTask?.Status, + }, + LinuxBuild = new() { - Percentile95 = times[(int)(times.Count * 0.05)], - Percentile90 = times[(int)(times.Count * 0.10)], - Percentile85 = times[(int)(times.Count * 0.16)], - Percentile80 = times[(int)(times.Count * 0.20)], - Mean = TimeSpan.FromTicks(times.Select(t => t.Ticks).Mean()), - StdDev = TimeSpan.FromTicks((long)times.Select(t => t.Ticks).StdDev()), - BuildCount = times.Count, - }; - BuildInfoCache.Set(cacheKey, result, TimeSpan.FromDays(1)); - return result; - } - catch (Exception e) - { - ApiConfig.Log.Error(e, "Failed to get Cirrus build stats"); - } - return ProjectBuildStats.Defaults; + Filename = linArtifact?.Path is string lp ? Path.GetFileName(lp) : null, + DownloadLink = linTask?.Id is string ltid && linArtifact?.Path is string ltap ? $"https://api.cirrus-ci.com/v1/artifact/task/{ltid}/Artifact/{ltap}" : null, + Status = linTask?.Status, + }, + MacBuild = new() + { + Filename = macArtifact?.Path is string mp ? Path.GetFileName(mp) : null, + DownloadLink = macTask?.Id is string mtid && macArtifact?.Path is string mtap ? $"https://api.cirrus-ci.com/v1/artifact/task/{mtid}/Artifact/{mtap}" : null, + Status= macTask?.Status, + } + }; } - - private static DateTime? GetFinishTime(IBaseNodeInfo node) - => node.LatestGroupTasks? - .Select(t => t?.FinalStatusTimestamp) - .Where(ts => ts > 0) - .ToList() is {Count: >0} finalTimes - ? FromTimestamp(finalTimes.Max()!.Value) - : node.ClockDurationInSeconds > 0 - ? FromTimestamp(node.BuildCreatedTimestamp).AddSeconds(node.ClockDurationInSeconds.Value) - : (DateTime?)null; - - [return: NotNullIfNotNull(nameof(DateTime))] - private static string? ToTimestamp(this DateTime? dateTime) => dateTime.HasValue ? (dateTime.Value.ToUniversalTime() - DateTime.UnixEpoch).TotalMilliseconds.ToString("0") : null; - private static DateTime FromTimestamp(long timestamp) => DateTime.UnixEpoch.AddMilliseconds(timestamp); + return null; } + + public static async Task GetPipelineDurationAsync(CancellationToken cancellationToken) + { + const string cacheKey = "project-build-stats"; + if (BuildInfoCache.TryGetValue(cacheKey, out ProjectBuildStats result)) + return result; + + try + { + var queryResult = await Client.GetLastFewBuilds.ExecuteAsync(200, cancellationToken).ConfigureAwait(false); + queryResult.EnsureNoErrors(); + + var times = ( + from edge in queryResult.Data?.OwnerRepository?.Builds?.Edges + let node = edge?.Node + where node?.Status == BuildStatus.Completed + let p = new {start = FromTimestamp(node.BuildCreatedTimestamp), finish = GetFinishTime(node)} + where p.finish.HasValue + let ts = p.finish!.Value - p.start + orderby ts descending + select ts + ).ToList(); + if (times.Count <= 10) + return ProjectBuildStats.Defaults; + + result = new() + { + Percentile95 = times[(int)(times.Count * 0.05)], + Percentile90 = times[(int)(times.Count * 0.10)], + Percentile85 = times[(int)(times.Count * 0.16)], + Percentile80 = times[(int)(times.Count * 0.20)], + Mean = TimeSpan.FromTicks(times.Select(t => t.Ticks).Mean()), + StdDev = TimeSpan.FromTicks((long)times.Select(t => t.Ticks).StdDev()), + BuildCount = times.Count, + }; + BuildInfoCache.Set(cacheKey, result, TimeSpan.FromDays(1)); + return result; + } + catch (Exception e) + { + ApiConfig.Log.Error(e, "Failed to get Cirrus build stats"); + } + return ProjectBuildStats.Defaults; + } + + private static DateTime? GetFinishTime(IBaseNodeInfo node) + => node.LatestGroupTasks? + .Select(t => t?.FinalStatusTimestamp) + .Where(ts => ts > 0) + .ToList() is {Count: >0} finalTimes + ? FromTimestamp(finalTimes.Max()!.Value) + : node.ClockDurationInSeconds > 0 + ? FromTimestamp(node.BuildCreatedTimestamp).AddSeconds(node.ClockDurationInSeconds.Value) + : (DateTime?)null; + + [return: NotNullIfNotNull(nameof(DateTime))] + private static string? ToTimestamp(this DateTime? dateTime) => dateTime.HasValue ? (dateTime.Value.ToUniversalTime() - DateTime.UnixEpoch).TotalMilliseconds.ToString("0") : null; + private static DateTime FromTimestamp(long timestamp) => DateTime.UnixEpoch.AddMilliseconds(timestamp); } \ No newline at end of file diff --git a/Clients/CirrusCiClient/POCOs/BuildInfo.cs b/Clients/CirrusCiClient/POCOs/BuildInfo.cs index 7871bbb1..dee63b46 100644 --- a/Clients/CirrusCiClient/POCOs/BuildInfo.cs +++ b/Clients/CirrusCiClient/POCOs/BuildInfo.cs @@ -1,21 +1,20 @@ using System; using CirrusCiClient.Generated; -namespace CirrusCiClient.POCOs +namespace CirrusCiClient.POCOs; + +public record BuildOSInfo { - public record BuildOSInfo - { - public string? Filename { get; init; } - public string? DownloadLink { get; init; } - public TaskStatus? Status { get; init; } - } - public record BuildInfo - { - public string? Commit { get; init; } - public DateTime StartTime { get; init; } - public DateTime? FinishTime { get; init; } - public BuildOSInfo? WindowsBuild { get; init; } - public BuildOSInfo? LinuxBuild { get; init; } - public BuildOSInfo? MacBuild { get; init; } - } + public string? Filename { get; init; } + public string? DownloadLink { get; init; } + public TaskStatus? Status { get; init; } +} +public record BuildInfo +{ + public string? Commit { get; init; } + public DateTime StartTime { get; init; } + public DateTime? FinishTime { get; init; } + public BuildOSInfo? WindowsBuild { get; init; } + public BuildOSInfo? LinuxBuild { get; init; } + public BuildOSInfo? MacBuild { get; init; } } \ No newline at end of file diff --git a/Clients/CirrusCiClient/POCOs/ProjectBuildStats.cs b/Clients/CirrusCiClient/POCOs/ProjectBuildStats.cs index e285ceb9..f318a40a 100644 --- a/Clients/CirrusCiClient/POCOs/ProjectBuildStats.cs +++ b/Clients/CirrusCiClient/POCOs/ProjectBuildStats.cs @@ -1,25 +1,24 @@ using System; -namespace CirrusCiClient.POCOs -{ - public record ProjectBuildStats - { - public TimeSpan Percentile95 { get; init; } - public TimeSpan Percentile90 { get; init; } - public TimeSpan Percentile85 { get; init; } - public TimeSpan Percentile80 { get; init; } - public TimeSpan Mean { get; init; } - public TimeSpan StdDev { get; init; } - public int BuildCount { get; init; } +namespace CirrusCiClient.POCOs; - public static readonly ProjectBuildStats Defaults = new() - { - Percentile95 = TimeSpan.FromSeconds(1120), - Percentile90 = TimeSpan.FromSeconds(900), - Percentile85 = TimeSpan.FromSeconds(870), - Percentile80 = TimeSpan.FromSeconds(865), - Mean = TimeSpan.FromSeconds(860), - StdDev = TimeSpan.FromSeconds(420), - }; - } +public record ProjectBuildStats +{ + public TimeSpan Percentile95 { get; init; } + public TimeSpan Percentile90 { get; init; } + public TimeSpan Percentile85 { get; init; } + public TimeSpan Percentile80 { get; init; } + public TimeSpan Mean { get; init; } + public TimeSpan StdDev { get; init; } + public int BuildCount { get; init; } + + public static readonly ProjectBuildStats Defaults = new() + { + Percentile95 = TimeSpan.FromSeconds(1120), + Percentile90 = TimeSpan.FromSeconds(900), + Percentile85 = TimeSpan.FromSeconds(870), + Percentile80 = TimeSpan.FromSeconds(865), + Mean = TimeSpan.FromSeconds(860), + StdDev = TimeSpan.FromSeconds(420), + }; } \ No newline at end of file diff --git a/Clients/CompatApiClient/ApiConfig.cs b/Clients/CompatApiClient/ApiConfig.cs index 62e3acde..8634ed6f 100644 --- a/Clients/CompatApiClient/ApiConfig.cs +++ b/Clients/CompatApiClient/ApiConfig.cs @@ -5,94 +5,93 @@ using System.Net.Http.Headers; using Microsoft.IO; using NLog; -namespace CompatApiClient +namespace CompatApiClient; + +using ReturnCodeType = Dictionary; + +public static class ApiConfig { - using ReturnCodeType = Dictionary; + public static readonly string ProductName = "RPCS3CompatibilityBot"; + public static readonly string ProductVersion = "2.0"; + public static readonly ProductInfoHeaderValue ProductInfoHeader = new(ProductName, ProductVersion); + public static int Version { get; } = 1; + public static Uri BaseUrl { get; } = new("https://rpcs3.net/compatibility"); + public static string DateInputFormat { get; } = "yyyy-M-d"; + public static string DateOutputFormat { get; } = "yyy-MM-dd"; + public static string DateQueryFormat { get; } = "yyyyMMdd"; - public static class ApiConfig + public static readonly ReturnCodeType ReturnCodes = new() { - public static readonly string ProductName = "RPCS3CompatibilityBot"; - public static readonly string ProductVersion = "2.0"; - public static readonly ProductInfoHeaderValue ProductInfoHeader = new(ProductName, ProductVersion); - public static int Version { get; } = 1; - public static Uri BaseUrl { get; } = new("https://rpcs3.net/compatibility"); - public static string DateInputFormat { get; } = "yyyy-M-d"; - public static string DateOutputFormat { get; } = "yyy-MM-dd"; - public static string DateQueryFormat { get; } = "yyyyMMdd"; + {0, (true, false, true, "Results successfully retrieved.")}, + {1, (false, false, true, "No results.") }, + {2, (true, false, true, "No match was found, displaying results for: ***{0}***.") }, + {-1, (false, true, false, "{0}: Internal error occurred, please contact Ani and Nicba1010") }, + {-2, (false, true, false, "{0}: API is undergoing maintenance, please try again later.") }, + {-3, (false, false, false, "Illegal characters found, please try again with a different search term.") }, + }; - public static readonly ReturnCodeType ReturnCodes = new() + public static readonly List ResultAmount = new(){25, 50, 100, 200}; + + public static readonly Dictionary Directions = new() + { + {'a', new []{"a", "asc", "ascending"}}, + {'d', new []{"d", "desc", "descending"} }, + }; + + public static readonly Dictionary Statuses = new() + { + {"all", 0 }, + {"playable", 1 }, + {"ingame", 2 }, + {"intro", 3 }, + {"loadable", 4 }, + {"nothing", 5 }, + }; + + public static readonly Dictionary SortTypes = new() + { + {"id", 1 }, + {"title", 2 }, + {"status", 3 }, + {"date", 4 }, + }; + + public static readonly Dictionary ReleaseTypes = new() + { + {'b', new[] {"b", "d", "disc", "disk", "bluray", "blu-ray"}}, + {'n', new[] {"n", "p", "PSN"}}, + }; + + public static readonly Dictionary ReverseDirections; + public static readonly Dictionary ReverseReleaseTypes; + + private static Dictionary Reverse(this Dictionary dic, IEqualityComparer comparer) + where TK: notnull + where TV: notnull + { + return ( + from kvp in dic + from val in kvp.Value + select (val, kvp.Key) + ).ToDictionary(rkvp => rkvp.val, rkvp => rkvp.Key, comparer); + } + + public static readonly ILogger Log; + public static readonly RecyclableMemoryStreamManager MemoryStreamManager = new(); + + static ApiConfig() + { + Log = LogManager.GetLogger("default"); + try { - {0, (true, false, true, "Results successfully retrieved.")}, - {1, (false, false, true, "No results.") }, - {2, (true, false, true, "No match was found, displaying results for: ***{0}***.") }, - {-1, (false, true, false, "{0}: Internal error occurred, please contact Ani and Nicba1010") }, - {-2, (false, true, false, "{0}: API is undergoing maintenance, please try again later.") }, - {-3, (false, false, false, "Illegal characters found, please try again with a different search term.") }, - }; - - public static readonly List ResultAmount = new(){25, 50, 100, 200}; - - public static readonly Dictionary Directions = new() - { - {'a', new []{"a", "asc", "ascending"}}, - {'d', new []{"d", "desc", "descending"} }, - }; - - public static readonly Dictionary Statuses = new() - { - {"all", 0 }, - {"playable", 1 }, - {"ingame", 2 }, - {"intro", 3 }, - {"loadable", 4 }, - {"nothing", 5 }, - }; - - public static readonly Dictionary SortTypes = new() - { - {"id", 1 }, - {"title", 2 }, - {"status", 3 }, - {"date", 4 }, - }; - - public static readonly Dictionary ReleaseTypes = new() - { - {'b', new[] {"b", "d", "disc", "disk", "bluray", "blu-ray"}}, - {'n', new[] {"n", "p", "PSN"}}, - }; - - public static readonly Dictionary ReverseDirections; - public static readonly Dictionary ReverseReleaseTypes; - - private static Dictionary Reverse(this Dictionary dic, IEqualityComparer comparer) - where TK: notnull - where TV: notnull - { - return ( - from kvp in dic - from val in kvp.Value - select (val, kvp.Key) - ).ToDictionary(rkvp => rkvp.val, rkvp => rkvp.Key, comparer); + ReverseDirections = Directions.Reverse(StringComparer.InvariantCultureIgnoreCase); + ReverseReleaseTypes = ReleaseTypes.Reverse(StringComparer.InvariantCultureIgnoreCase); } - - public static readonly ILogger Log; - public static readonly RecyclableMemoryStreamManager MemoryStreamManager = new(); - - static ApiConfig() + catch (Exception e) { - Log = LogManager.GetLogger("default"); - try - { - ReverseDirections = Directions.Reverse(StringComparer.InvariantCultureIgnoreCase); - ReverseReleaseTypes = ReleaseTypes.Reverse(StringComparer.InvariantCultureIgnoreCase); - } - catch (Exception e) - { - Log.Fatal(e); - ReverseDirections = new Dictionary(); - ReverseReleaseTypes = new Dictionary(); - } + Log.Fatal(e); + ReverseDirections = new Dictionary(); + ReverseReleaseTypes = new Dictionary(); } } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Client.cs b/Clients/CompatApiClient/Client.cs index a8799e14..5568e257 100644 --- a/Clients/CompatApiClient/Client.cs +++ b/Clients/CompatApiClient/Client.cs @@ -11,131 +11,130 @@ using CompatApiClient.POCOs; using CompatApiClient.Utils; using Microsoft.Extensions.Caching.Memory; -namespace CompatApiClient +namespace CompatApiClient; + +public class Client: IDisposable { - public class Client: IDisposable + private readonly HttpClient client; + private readonly JsonSerializerOptions jsonOptions; + private static readonly MemoryCache ResponseCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromHours(1) }); + + public Client() { - private readonly HttpClient client; - private readonly JsonSerializerOptions jsonOptions; - private static readonly MemoryCache ResponseCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromHours(1) }); - - public Client() + client = HttpClientFactory.Create(new CompressionMessageHandler()); + jsonOptions = new JsonSerializerOptions { - client = HttpClientFactory.Create(new CompressionMessageHandler()); - jsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - IncludeFields = true, - Converters = { new CompatApiCommitHashConverter(), }, - }; - } + PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + Converters = { new CompatApiCommitHashConverter(), }, + }; + } - //todo: cache results - public async Task GetCompatResultAsync(RequestBuilder requestBuilder, CancellationToken cancellationToken) + //todo: cache results + public async Task GetCompatResultAsync(RequestBuilder requestBuilder, CancellationToken cancellationToken) + { + var startTime = DateTime.UtcNow; + var url = requestBuilder.Build(); + var tries = 0; + do { - var startTime = DateTime.UtcNow; - var url = requestBuilder.Build(); - var tries = 0; - do + try { + using var message = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); try { - using var message = new HttpRequestMessage(HttpMethod.Get, url); - using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); - try + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + var result = await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null) { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - var result = await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); - if (result != null) - { - result.RequestBuilder = requestBuilder; - result.RequestDuration = DateTime.UtcNow - startTime; - } - return result; - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response, false); + result.RequestBuilder = requestBuilder; + result.RequestDuration = DateTime.UtcNow - startTime; } + return result; } catch (Exception e) { - ApiConfig.Log.Warn(e); + ConsoleLogger.PrintError(e, response, false); } - tries++; - } while (tries < 3); - throw new HttpRequestException("Couldn't communicate with the API"); - } - - public async Task GetCompatListSnapshotAsync(CancellationToken cancellationToken) - { - var url = "https://rpcs3.net/compatibility?api=v1&export"; - if (ResponseCache.TryGetValue(url, out CompatResult? result)) - return result; - - var tries = 0; - do + } + catch (Exception e) { + ApiConfig.Log.Warn(e); + } + tries++; + } while (tries < 3); + throw new HttpRequestException("Couldn't communicate with the API"); + } + + public async Task GetCompatListSnapshotAsync(CancellationToken cancellationToken) + { + var url = "https://rpcs3.net/compatibility?api=v1&export"; + if (ResponseCache.TryGetValue(url, out CompatResult? result)) + return result; + + var tries = 0; + do + { + try + { + using var message = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); try { - using var message = new HttpRequestMessage(HttpMethod.Get, url); - using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); - try - { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - result = await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); - if (result != null) - ResponseCache.Set(url, result, TimeSpan.FromDays(1)); - return result; - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response, false); - } + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + result = await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null) + ResponseCache.Set(url, result, TimeSpan.FromDays(1)); + return result; } catch (Exception e) { - ApiConfig.Log.Warn(e); + ConsoleLogger.PrintError(e, response, false); } - tries++; - } while (tries < 3); - throw new HttpRequestException("Couldn't communicate with the API"); - } - - public async Task GetUpdateAsync(CancellationToken cancellationToken, string? commit = null) - { - if (string.IsNullOrEmpty(commit)) - commit = "somecommit"; - var tries = 3; - do + } + catch (Exception e) { + ApiConfig.Log.Warn(e); + } + tries++; + } while (tries < 3); + throw new HttpRequestException("Couldn't communicate with the API"); + } + + public async Task GetUpdateAsync(CancellationToken cancellationToken, string? commit = null) + { + if (string.IsNullOrEmpty(commit)) + commit = "somecommit"; + var tries = 3; + do + { + try + { + using var message = new HttpRequestMessage(HttpMethod.Get, "https://update.rpcs3.net/?api=v1&c=" + commit); + using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); try { - using var message = new HttpRequestMessage(HttpMethod.Get, "https://update.rpcs3.net/?api=v1&c=" + commit); - using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); - try - { - return await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response, false); - } + return await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception e) { - ApiConfig.Log.Warn(e); + ConsoleLogger.PrintError(e, response, false); } - tries++; - } while (tries < 3); - return null; - } + } + catch (Exception e) + { + ApiConfig.Log.Warn(e); + } + tries++; + } while (tries < 3); + return null; + } - public void Dispose() - { - GC.SuppressFinalize(this); - client.Dispose(); - } + public void Dispose() + { + GC.SuppressFinalize(this); + client.Dispose(); } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Compression/CompressedContent.cs b/Clients/CompatApiClient/Compression/CompressedContent.cs index 618ab3b3..c31e845a 100644 --- a/Clients/CompatApiClient/Compression/CompressedContent.cs +++ b/Clients/CompatApiClient/Compression/CompressedContent.cs @@ -4,39 +4,38 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -namespace CompatApiClient.Compression +namespace CompatApiClient.Compression; + +public class CompressedContent : HttpContent { - public class CompressedContent : HttpContent + private readonly HttpContent content; + private readonly ICompressor compressor; + + public CompressedContent(HttpContent content, ICompressor compressor) { - private readonly HttpContent content; - private readonly ICompressor compressor; + this.content = content; + this.compressor = compressor; + AddHeaders(); + } - public CompressedContent(HttpContent content, ICompressor compressor) - { - this.content = content; - this.compressor = compressor; - AddHeaders(); - } + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } - protected override bool TryComputeLength(out long length) - { - length = -1; - return false; - } + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + var contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); + var compressedLength = await compressor.CompressAsync(contentStream, stream).ConfigureAwait(false); + Headers.ContentLength = compressedLength; + } - protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) - { - var contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); - var compressedLength = await compressor.CompressAsync(contentStream, stream).ConfigureAwait(false); - Headers.ContentLength = compressedLength; - } - - private void AddHeaders() - { - foreach (var (key, value) in content.Headers) - Headers.TryAddWithoutValidation(key, value); - Headers.ContentEncoding.Add(compressor.EncodingType); - Headers.ContentLength = null; - } + private void AddHeaders() + { + foreach (var (key, value) in content.Headers) + Headers.TryAddWithoutValidation(key, value); + Headers.ContentEncoding.Add(compressor.EncodingType); + Headers.ContentLength = null; } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Compression/CompressionMessageHandler.cs b/Clients/CompatApiClient/Compression/CompressionMessageHandler.cs index fbc98e7a..59d3ad94 100644 --- a/Clients/CompatApiClient/Compression/CompressionMessageHandler.cs +++ b/Clients/CompatApiClient/Compression/CompressionMessageHandler.cs @@ -5,64 +5,63 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -namespace CompatApiClient.Compression +namespace CompatApiClient.Compression; + +public class CompressionMessageHandler : DelegatingHandler { - public class CompressionMessageHandler : DelegatingHandler + public ICollection Compressors { get; } + public static readonly string PostCompressionFlag = "X-Set-Content-Encoding"; + public static readonly string[] DefaultContentEncodings = { "gzip", "deflate" }; + public static readonly string DefaultAcceptEncodings = "gzip, deflate"; + + private readonly bool isServer; + private readonly bool isClient; + + public CompressionMessageHandler(bool isServer = false) { - public ICollection Compressors { get; } - public static readonly string PostCompressionFlag = "X-Set-Content-Encoding"; - public static readonly string[] DefaultContentEncodings = { "gzip", "deflate" }; - public static readonly string DefaultAcceptEncodings = "gzip, deflate"; - - private readonly bool isServer; - private readonly bool isClient; - - public CompressionMessageHandler(bool isServer = false) + this.isServer = isServer; + isClient = !isServer; + Compressors = new ICompressor[] { - this.isServer = isServer; - isClient = !isServer; - Compressors = new ICompressor[] - { - new GZipCompressor(), - new DeflateCompressor(), - }; - } + new GZipCompressor(), + new DeflateCompressor(), + }; + } - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (isServer + && request.Content?.Headers.ContentEncoding != null + && request.Content.Headers.ContentEncoding.FirstOrDefault() is string serverEncoding + && Compressors.FirstOrDefault(c => c.EncodingType.Equals(serverEncoding, StringComparison.InvariantCultureIgnoreCase)) is ICompressor serverDecompressor) { - if (isServer - && request.Content?.Headers.ContentEncoding != null - && request.Content.Headers.ContentEncoding.FirstOrDefault() is string serverEncoding - && Compressors.FirstOrDefault(c => c.EncodingType.Equals(serverEncoding, StringComparison.InvariantCultureIgnoreCase)) is ICompressor serverDecompressor) - { - request.Content = new DecompressedContent(request.Content, serverDecompressor); - } - else if (isClient - && (request.Method == HttpMethod.Post || request.Method == HttpMethod.Put) - && request.Content != null - && request.Headers.TryGetValues(PostCompressionFlag, out var compressionFlagValues) - && compressionFlagValues.FirstOrDefault() is string compressionFlag - && Compressors.FirstOrDefault(c => c.EncodingType.Equals(compressionFlag, StringComparison.InvariantCultureIgnoreCase)) is ICompressor clientCompressor) - { - request.Content = new CompressedContent(request.Content, clientCompressor); - } - request.Headers.Remove(PostCompressionFlag); - //ApiConfig.Log.Trace($"{request.Method} {request.RequestUri}"); - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - //ApiConfig.Log.Trace($"Response: {response.StatusCode} {request.RequestUri}"); - if (isClient - && response.Content.Headers.ContentEncoding.FirstOrDefault() is string clientEncoding - && Compressors.FirstOrDefault(c => c.EncodingType.Equals(clientEncoding, StringComparison.InvariantCultureIgnoreCase)) is ICompressor clientDecompressor) - { - response.Content = new DecompressedContent(response.Content, clientDecompressor); - } - else if (isServer - && request.Headers.AcceptEncoding.FirstOrDefault() is {} acceptEncoding - && Compressors.FirstOrDefault(c => c.EncodingType.Equals(acceptEncoding.Value, StringComparison.InvariantCultureIgnoreCase)) is ICompressor serverCompressor) - { - response.Content = new CompressedContent(response.Content, serverCompressor); - } - return response; + request.Content = new DecompressedContent(request.Content, serverDecompressor); } + else if (isClient + && (request.Method == HttpMethod.Post || request.Method == HttpMethod.Put) + && request.Content != null + && request.Headers.TryGetValues(PostCompressionFlag, out var compressionFlagValues) + && compressionFlagValues.FirstOrDefault() is string compressionFlag + && Compressors.FirstOrDefault(c => c.EncodingType.Equals(compressionFlag, StringComparison.InvariantCultureIgnoreCase)) is ICompressor clientCompressor) + { + request.Content = new CompressedContent(request.Content, clientCompressor); + } + request.Headers.Remove(PostCompressionFlag); + //ApiConfig.Log.Trace($"{request.Method} {request.RequestUri}"); + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + //ApiConfig.Log.Trace($"Response: {response.StatusCode} {request.RequestUri}"); + if (isClient + && response.Content.Headers.ContentEncoding.FirstOrDefault() is string clientEncoding + && Compressors.FirstOrDefault(c => c.EncodingType.Equals(clientEncoding, StringComparison.InvariantCultureIgnoreCase)) is ICompressor clientDecompressor) + { + response.Content = new DecompressedContent(response.Content, clientDecompressor); + } + else if (isServer + && request.Headers.AcceptEncoding.FirstOrDefault() is {} acceptEncoding + && Compressors.FirstOrDefault(c => c.EncodingType.Equals(acceptEncoding.Value, StringComparison.InvariantCultureIgnoreCase)) is ICompressor serverCompressor) + { + response.Content = new CompressedContent(response.Content, serverCompressor); + } + return response; } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Compression/Compressor.cs b/Clients/CompatApiClient/Compression/Compressor.cs index c772595b..2a965f66 100644 --- a/Clients/CompatApiClient/Compression/Compressor.cs +++ b/Clients/CompatApiClient/Compression/Compressor.cs @@ -1,32 +1,31 @@ using System.IO; using System.Threading.Tasks; -namespace CompatApiClient.Compression +namespace CompatApiClient.Compression; + +public abstract class Compressor : ICompressor { - public abstract class Compressor : ICompressor + public abstract string EncodingType { get; } + protected abstract Stream CreateCompressionStream(Stream output); + protected abstract Stream CreateDecompressionStream(Stream input); + + public virtual async Task CompressAsync(Stream source, Stream destination) { - public abstract string EncodingType { get; } - protected abstract Stream CreateCompressionStream(Stream output); - protected abstract Stream CreateDecompressionStream(Stream input); + await using var memStream = ApiConfig.MemoryStreamManager.GetStream(); + await using (var compressed = CreateCompressionStream(memStream)) + await source.CopyToAsync(compressed).ConfigureAwait(false); + memStream.Seek(0, SeekOrigin.Begin); + await memStream.CopyToAsync(destination).ConfigureAwait(false); + return memStream.Length; + } - public virtual async Task CompressAsync(Stream source, Stream destination) - { - await using var memStream = ApiConfig.MemoryStreamManager.GetStream(); - await using (var compressed = CreateCompressionStream(memStream)) - await source.CopyToAsync(compressed).ConfigureAwait(false); - memStream.Seek(0, SeekOrigin.Begin); - await memStream.CopyToAsync(destination).ConfigureAwait(false); - return memStream.Length; - } - - public virtual async Task DecompressAsync(Stream source, Stream destination) - { - await using var memStream = ApiConfig.MemoryStreamManager.GetStream(); - await using (var decompressed = CreateDecompressionStream(source)) - await decompressed.CopyToAsync(memStream).ConfigureAwait(false); - memStream.Seek(0, SeekOrigin.Begin); - await memStream.CopyToAsync(destination).ConfigureAwait(false); - return memStream.Length; - } + public virtual async Task DecompressAsync(Stream source, Stream destination) + { + await using var memStream = ApiConfig.MemoryStreamManager.GetStream(); + await using (var decompressed = CreateDecompressionStream(source)) + await decompressed.CopyToAsync(memStream).ConfigureAwait(false); + memStream.Seek(0, SeekOrigin.Begin); + await memStream.CopyToAsync(destination).ConfigureAwait(false); + return memStream.Length; } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Compression/DecompressedContent.cs b/Clients/CompatApiClient/Compression/DecompressedContent.cs index 618b539c..77945fe9 100644 --- a/Clients/CompatApiClient/Compression/DecompressedContent.cs +++ b/Clients/CompatApiClient/Compression/DecompressedContent.cs @@ -4,39 +4,38 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -namespace CompatApiClient.Compression +namespace CompatApiClient.Compression; + +public class DecompressedContent : HttpContent { - public class DecompressedContent : HttpContent + private readonly HttpContent content; + private readonly ICompressor compressor; + + public DecompressedContent(HttpContent content, ICompressor compressor) { - private readonly HttpContent content; - private readonly ICompressor compressor; + this.content = content; + this.compressor = compressor; + RemoveHeaders(); + } - public DecompressedContent(HttpContent content, ICompressor compressor) - { - this.content = content; - this.compressor = compressor; - RemoveHeaders(); - } + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } - protected override bool TryComputeLength(out long length) - { - length = -1; - return false; - } + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + var contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); + var decompressedLength = await compressor.DecompressAsync(contentStream, stream).ConfigureAwait(false); + Headers.ContentLength = decompressedLength; + } - protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) - { - var contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); - var decompressedLength = await compressor.DecompressAsync(contentStream, stream).ConfigureAwait(false); - Headers.ContentLength = decompressedLength; - } - - private void RemoveHeaders() - { - foreach (var (key, value) in content.Headers) - Headers.TryAddWithoutValidation(key, value); - Headers.ContentEncoding.Clear(); - Headers.ContentLength = null; - } + private void RemoveHeaders() + { + foreach (var (key, value) in content.Headers) + Headers.TryAddWithoutValidation(key, value); + Headers.ContentEncoding.Clear(); + Headers.ContentLength = null; } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Compression/DeflateCompressor.cs b/Clients/CompatApiClient/Compression/DeflateCompressor.cs index cb143bd9..5e83d43a 100644 --- a/Clients/CompatApiClient/Compression/DeflateCompressor.cs +++ b/Clients/CompatApiClient/Compression/DeflateCompressor.cs @@ -1,16 +1,15 @@ using System.IO; using System.IO.Compression; -namespace CompatApiClient.Compression +namespace CompatApiClient.Compression; + +public class DeflateCompressor : Compressor { - public class DeflateCompressor : Compressor - { - public override string EncodingType => "deflate"; + public override string EncodingType => "deflate"; - protected override Stream CreateCompressionStream(Stream output) - => new DeflateStream(output, CompressionMode.Compress, true); + protected override Stream CreateCompressionStream(Stream output) + => new DeflateStream(output, CompressionMode.Compress, true); - protected override Stream CreateDecompressionStream(Stream input) - => new DeflateStream(input, CompressionMode.Decompress, true); - } + protected override Stream CreateDecompressionStream(Stream input) + => new DeflateStream(input, CompressionMode.Decompress, true); } \ No newline at end of file diff --git a/Clients/CompatApiClient/Compression/GZipCompressor.cs b/Clients/CompatApiClient/Compression/GZipCompressor.cs index 6e234c4d..b67d2a40 100644 --- a/Clients/CompatApiClient/Compression/GZipCompressor.cs +++ b/Clients/CompatApiClient/Compression/GZipCompressor.cs @@ -1,16 +1,15 @@ using System.IO; using System.IO.Compression; -namespace CompatApiClient.Compression +namespace CompatApiClient.Compression; + +public class GZipCompressor : Compressor { - public class GZipCompressor : Compressor - { - public override string EncodingType => "gzip"; + public override string EncodingType => "gzip"; - protected override Stream CreateCompressionStream(Stream output) - => new GZipStream(output, CompressionMode.Compress, true); + protected override Stream CreateCompressionStream(Stream output) + => new GZipStream(output, CompressionMode.Compress, true); - protected override Stream CreateDecompressionStream(Stream input) - => new GZipStream(input, CompressionMode.Decompress, true); - } + protected override Stream CreateDecompressionStream(Stream input) + => new GZipStream(input, CompressionMode.Decompress, true); } \ No newline at end of file diff --git a/Clients/CompatApiClient/Compression/ICompressor.cs b/Clients/CompatApiClient/Compression/ICompressor.cs index bd9d4d45..716d3179 100644 --- a/Clients/CompatApiClient/Compression/ICompressor.cs +++ b/Clients/CompatApiClient/Compression/ICompressor.cs @@ -1,12 +1,11 @@ using System.IO; using System.Threading.Tasks; -namespace CompatApiClient.Compression +namespace CompatApiClient.Compression; + +public interface ICompressor { - public interface ICompressor - { - string EncodingType { get; } - Task CompressAsync(Stream source, Stream destination); - Task DecompressAsync(Stream source, Stream destination); - } + string EncodingType { get; } + Task CompressAsync(Stream source, Stream destination); + Task DecompressAsync(Stream source, Stream destination); } \ No newline at end of file diff --git a/Clients/CompatApiClient/Formatters/CompatApiCommitHashConverter.cs b/Clients/CompatApiClient/Formatters/CompatApiCommitHashConverter.cs index 691a157f..a35ad42e 100644 --- a/Clients/CompatApiClient/Formatters/CompatApiCommitHashConverter.cs +++ b/Clients/CompatApiClient/Formatters/CompatApiCommitHashConverter.cs @@ -2,25 +2,24 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace CompatApiClient -{ - public sealed class CompatApiCommitHashConverter : JsonConverter - { - public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Number - && !reader.HasValueSequence - && reader.ValueSpan.Length == 1 - && reader.ValueSpan[0] == (byte)'0') - { - _ = reader.GetInt32(); - return null; - } +namespace CompatApiClient; - return reader.GetString(); +public sealed class CompatApiCommitHashConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number + && !reader.HasValueSequence + && reader.ValueSpan.Length == 1 + && reader.ValueSpan[0] == (byte)'0') + { + _ = reader.GetInt32(); + return null; } - public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) - => writer.WriteStringValue(value); + return reader.GetString(); } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + => writer.WriteStringValue(value); } \ No newline at end of file diff --git a/Clients/CompatApiClient/Formatters/DashedNamingPolicy.cs b/Clients/CompatApiClient/Formatters/DashedNamingPolicy.cs index 2f42363c..c972b5fd 100644 --- a/Clients/CompatApiClient/Formatters/DashedNamingPolicy.cs +++ b/Clients/CompatApiClient/Formatters/DashedNamingPolicy.cs @@ -1,9 +1,8 @@ using System.Text.Json; -namespace CompatApiClient +namespace CompatApiClient; + +public sealed class DashedNamingPolicy: JsonNamingPolicy { - public sealed class DashedNamingPolicy: JsonNamingPolicy - { - public override string ConvertName(string name) => NamingStyles.Dashed(name); - } + public override string ConvertName(string name) => NamingStyles.Dashed(name); } \ No newline at end of file diff --git a/Clients/CompatApiClient/Formatters/NamingStyles.cs b/Clients/CompatApiClient/Formatters/NamingStyles.cs index d6133855..1af2110a 100644 --- a/Clients/CompatApiClient/Formatters/NamingStyles.cs +++ b/Clients/CompatApiClient/Formatters/NamingStyles.cs @@ -1,58 +1,57 @@ using System; using System.Text; -namespace CompatApiClient +namespace CompatApiClient; + +public static class NamingStyles { - public static class NamingStyles + public static string CamelCase(string value) { - public static string CamelCase(string value) - { - if (value == null) - throw new ArgumentNullException(nameof(value)); + if (value == null) + throw new ArgumentNullException(nameof(value)); - if (value.Length > 0) - { - if (char.IsUpper(value[0])) - value = char.ToLower(value[0]) + value[1..]; - } + if (value.Length > 0) + { + if (char.IsUpper(value[0])) + value = char.ToLower(value[0]) + value[1..]; + } + return value; + } + + public static string Dashed(string value) + { + return Delimitied(value, '-'); + } + + public static string Underscore(string value) + { + return Delimitied(value, '_'); + } + + private static string Delimitied(string value, char separator) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + if (value.Length == 0) return value; - } - public static string Dashed(string value) + var hasPrefix = true; + var builder = new StringBuilder(value.Length + 3); + foreach (var c in value) { - return Delimitied(value, '-'); - } - - public static string Underscore(string value) - { - return Delimitied(value, '_'); - } - - private static string Delimitied(string value, char separator) - { - if (value == null) - throw new ArgumentNullException(nameof(value)); - - if (value.Length == 0) - return value; - - var hasPrefix = true; - var builder = new StringBuilder(value.Length + 3); - foreach (var c in value) + var ch = c; + if (char.IsUpper(ch)) { - var ch = c; - if (char.IsUpper(ch)) - { - ch = char.ToLower(ch); - if (!hasPrefix) - builder.Append(separator); - hasPrefix = true; - } - else - hasPrefix = false; - builder.Append(ch); + ch = char.ToLower(ch); + if (!hasPrefix) + builder.Append(separator); + hasPrefix = true; } - return builder.ToString(); + else + hasPrefix = false; + builder.Append(ch); } + return builder.ToString(); } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Formatters/SnakeCaseNamingPolicy.cs b/Clients/CompatApiClient/Formatters/SnakeCaseNamingPolicy.cs index 37cdea0b..0fff07e4 100644 --- a/Clients/CompatApiClient/Formatters/SnakeCaseNamingPolicy.cs +++ b/Clients/CompatApiClient/Formatters/SnakeCaseNamingPolicy.cs @@ -1,9 +1,8 @@ using System.Text.Json; -namespace CompatApiClient +namespace CompatApiClient; + +public sealed class SnakeCaseNamingPolicy: JsonNamingPolicy { - public sealed class SnakeCaseNamingPolicy: JsonNamingPolicy - { - public override string ConvertName(string name) => NamingStyles.Underscore(name); - } + public override string ConvertName(string name) => NamingStyles.Underscore(name); } \ No newline at end of file diff --git a/Clients/CompatApiClient/Formatters/SpecialJsonNamingPolicy.cs b/Clients/CompatApiClient/Formatters/SpecialJsonNamingPolicy.cs index 4ad18a96..db23f9fb 100644 --- a/Clients/CompatApiClient/Formatters/SpecialJsonNamingPolicy.cs +++ b/Clients/CompatApiClient/Formatters/SpecialJsonNamingPolicy.cs @@ -1,8 +1,7 @@ -namespace CompatApiClient.Formatters +namespace CompatApiClient.Formatters; + +public static class SpecialJsonNamingPolicy { - public static class SpecialJsonNamingPolicy - { - public static SnakeCaseNamingPolicy SnakeCase { get; } = new(); - public static DashedNamingPolicy Dashed { get; } = new(); - } + public static SnakeCaseNamingPolicy SnakeCase { get; } = new(); + public static DashedNamingPolicy Dashed { get; } = new(); } \ No newline at end of file diff --git a/Clients/CompatApiClient/POCOs/CompatResult.cs b/Clients/CompatApiClient/POCOs/CompatResult.cs index d87b3bf2..54a890b4 100644 --- a/Clients/CompatApiClient/POCOs/CompatResult.cs +++ b/Clients/CompatApiClient/POCOs/CompatResult.cs @@ -2,42 +2,40 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace CompatApiClient.POCOs +namespace CompatApiClient.POCOs; +#nullable disable + +public class CompatResult { - #nullable disable + public int ReturnCode; + public string SearchTerm; + public Dictionary Results; + + [JsonIgnore] + public TimeSpan RequestDuration; + [JsonIgnore] + public RequestBuilder RequestBuilder; +} + +public class TitleInfo +{ + public static readonly TitleInfo Maintenance = new() { Status = "Maintenance" }; + public static readonly TitleInfo CommunicationError = new() { Status = "Error" }; + public static readonly TitleInfo Unknown = new() { Status = "Unknown" }; + + public string Title; + [JsonPropertyName("alternative-title")] + public string AlternativeTitle; + [JsonPropertyName("wiki-title")] + public string WikiTitle; + public string Status; + public string Date; + public int Thread; + public string Commit; + public int? Pr; + public int? Network; + public string Update; + public bool? UsingLocalCache; +} - public class CompatResult - { - public int ReturnCode; - public string SearchTerm; - public Dictionary Results; - - [JsonIgnore] - public TimeSpan RequestDuration; - [JsonIgnore] - public RequestBuilder RequestBuilder; - } - - public class TitleInfo - { - public static readonly TitleInfo Maintenance = new() { Status = "Maintenance" }; - public static readonly TitleInfo CommunicationError = new() { Status = "Error" }; - public static readonly TitleInfo Unknown = new() { Status = "Unknown" }; - - public string Title; - [JsonPropertyName("alternative-title")] - public string AlternativeTitle; - [JsonPropertyName("wiki-title")] - public string WikiTitle; - public string Status; - public string Date; - public int Thread; - public string Commit; - public int? Pr; - public int? Network; - public string Update; - public bool? UsingLocalCache; - } - - #nullable restore -} \ No newline at end of file +#nullable restore \ No newline at end of file diff --git a/Clients/CompatApiClient/POCOs/UpdateInfo.cs b/Clients/CompatApiClient/POCOs/UpdateInfo.cs index 165b840a..ea86c05b 100644 --- a/Clients/CompatApiClient/POCOs/UpdateInfo.cs +++ b/Clients/CompatApiClient/POCOs/UpdateInfo.cs @@ -1,27 +1,25 @@ -namespace CompatApiClient.POCOs +namespace CompatApiClient.POCOs; +#nullable disable + +public class UpdateInfo { - #nullable disable - - public class UpdateInfo - { - public int ReturnCode; - public BuildInfo LatestBuild; - public BuildInfo CurrentBuild; - } + public int ReturnCode; + public BuildInfo LatestBuild; + public BuildInfo CurrentBuild; +} - public class BuildInfo - { - public int? Pr; - public string Datetime; - public BuildLink Windows; - public BuildLink Linux; - public BuildLink Mac; - } +public class BuildInfo +{ + public int? Pr; + public string Datetime; + public BuildLink Windows; + public BuildLink Linux; + public BuildLink Mac; +} - public class BuildLink - { - public string Download; - } +public class BuildLink +{ + public string Download; +} - #nullable restore -} \ No newline at end of file +#nullable restore \ No newline at end of file diff --git a/Clients/CompatApiClient/RequestBuilder.cs b/Clients/CompatApiClient/RequestBuilder.cs index 77b09594..3350c479 100644 --- a/Clients/CompatApiClient/RequestBuilder.cs +++ b/Clients/CompatApiClient/RequestBuilder.cs @@ -2,33 +2,32 @@ using System.Collections.Generic; using CompatApiClient.Utils; -namespace CompatApiClient +namespace CompatApiClient; + +public class RequestBuilder { - public class RequestBuilder + public string? Search { get; private set; } = ""; + public int AmountRequested { get; } = ApiConfig.ResultAmount[0]; + + private RequestBuilder() {} + + public static RequestBuilder Start() => new(); + + public RequestBuilder SetSearch(string search) { - public string? Search { get; private set; } = ""; - public int AmountRequested { get; } = ApiConfig.ResultAmount[0]; + Search = search; + return this; + } - private RequestBuilder() {} - - public static RequestBuilder Start() => new(); - - public RequestBuilder SetSearch(string search) + public Uri Build(bool apiCall = true) + { + var parameters = new Dictionary { - Search = search; - return this; - } - - public Uri Build(bool apiCall = true) - { - var parameters = new Dictionary - { - {"g", Search}, - {"r", AmountRequested.ToString()}, - }; - if (apiCall) - parameters["api"] = "v" + ApiConfig.Version; - return ApiConfig.BaseUrl.SetQueryParameters(parameters); - } + {"g", Search}, + {"r", AmountRequested.ToString()}, + }; + if (apiCall) + parameters["api"] = "v" + ApiConfig.Version; + return ApiConfig.BaseUrl.SetQueryParameters(parameters); } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Utils/ConsoleLogger.cs b/Clients/CompatApiClient/Utils/ConsoleLogger.cs index 637ed0bf..ff6275bb 100644 --- a/Clients/CompatApiClient/Utils/ConsoleLogger.cs +++ b/Clients/CompatApiClient/Utils/ConsoleLogger.cs @@ -1,26 +1,25 @@ using System; using System.Net.Http; -namespace CompatApiClient.Utils -{ - public static class ConsoleLogger - { - public static void PrintError(Exception e, HttpResponseMessage? response, bool isError = true) - { - if (isError) - ApiConfig.Log.Error(e, "HTTP error"); - else - ApiConfig.Log.Warn(e, "HTTP error"); - if (response == null) - return; +namespace CompatApiClient.Utils; - try - { - ApiConfig.Log.Info(response.RequestMessage?.RequestUri); - var msg = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - ApiConfig.Log.Warn(msg); - } - catch { } +public static class ConsoleLogger +{ + public static void PrintError(Exception e, HttpResponseMessage? response, bool isError = true) + { + if (isError) + ApiConfig.Log.Error(e, "HTTP error"); + else + ApiConfig.Log.Warn(e, "HTTP error"); + if (response == null) + return; + + try + { + ApiConfig.Log.Info(response.RequestMessage?.RequestUri); + var msg = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + ApiConfig.Log.Warn(msg); } + catch { } } -} +} \ No newline at end of file diff --git a/Clients/CompatApiClient/Utils/Statistics.cs b/Clients/CompatApiClient/Utils/Statistics.cs index 649634e9..c9977138 100644 --- a/Clients/CompatApiClient/Utils/Statistics.cs +++ b/Clients/CompatApiClient/Utils/Statistics.cs @@ -1,40 +1,39 @@ using System; using System.Collections.Generic; -namespace CompatApiClient.Utils +namespace CompatApiClient.Utils; + +public static class Statistics { - public static class Statistics + public static long Mean(this IEnumerable data) { - public static long Mean(this IEnumerable data) + System.Numerics.BigInteger sum = 0; + var itemCount = 0; + foreach (var value in data) { - System.Numerics.BigInteger sum = 0; - var itemCount = 0; - foreach (var value in data) - { - sum += value; - itemCount ++; - } - if (itemCount == 0) - throw new ArgumentException("Sequence must contain elements", nameof(data)); - - return (long)(sum / itemCount); + sum += value; + itemCount ++; } + if (itemCount == 0) + throw new ArgumentException("Sequence must contain elements", nameof(data)); - public static double StdDev(this IEnumerable data) + return (long)(sum / itemCount); + } + + public static double StdDev(this IEnumerable data) + { + System.Numerics.BigInteger σx = 0, σx2 = 0; + var n = 0; + foreach (var value in data) { - System.Numerics.BigInteger σx = 0, σx2 = 0; - var n = 0; - foreach (var value in data) - { - σx += value; - σx2 += (System.Numerics.BigInteger)value * value; - n++; - } - if (n == 0) - throw new ArgumentException("Sequence must contain elements", nameof(data)); - - var σ2 = σx * σx; - return Math.Sqrt((double)((n * σx2) - σ2) / ((n - 1) * n)); + σx += value; + σx2 += (System.Numerics.BigInteger)value * value; + n++; } + if (n == 0) + throw new ArgumentException("Sequence must contain elements", nameof(data)); + + var σ2 = σx * σx; + return Math.Sqrt((double)((n * σx2) - σ2) / ((n - 1) * n)); } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Utils/UriExtensions.cs b/Clients/CompatApiClient/Utils/UriExtensions.cs index 4314e32e..bc54efde 100644 --- a/Clients/CompatApiClient/Utils/UriExtensions.cs +++ b/Clients/CompatApiClient/Utils/UriExtensions.cs @@ -5,117 +5,116 @@ using System.Net; using System.Net.Http; using System.Text; -namespace CompatApiClient.Utils +namespace CompatApiClient.Utils; + +public static class UriExtensions { - public static class UriExtensions + private static readonly Uri FakeHost = new("sc://q"); // s:// will be parsed as file:///s:// for some reason + + public static NameValueCollection ParseQueryString(Uri uri) { - private static readonly Uri FakeHost = new("sc://q"); // s:// will be parsed as file:///s:// for some reason + if (!uri.IsAbsoluteUri) + uri = new Uri(FakeHost, uri); + return uri.ParseQueryString(); + } - public static NameValueCollection ParseQueryString(Uri uri) + public static string? GetQueryParameter(this Uri uri, string name) + { + var parameters = ParseQueryString(uri); + return parameters[name]; + } + + public static Uri AddQueryParameter(this Uri uri, string name, string value) + { + var queryValue = WebUtility.UrlEncode(name) + "=" + WebUtility.UrlEncode(value); + return AddQueryValue(uri, queryValue); + } + + public static Uri AddQueryParameters(Uri uri, IEnumerable> parameters) + { + var builder = new StringBuilder(); + foreach (var param in parameters) { - if (!uri.IsAbsoluteUri) - uri = new Uri(FakeHost, uri); - return uri.ParseQueryString(); + if (builder.Length > 0) + builder.Append('&'); + builder.Append(Uri.EscapeDataString(param.Key)); + builder.Append('='); + builder.Append(Uri.EscapeDataString(param.Value)); } + return AddQueryValue(uri, builder.ToString()); + } - public static string? GetQueryParameter(this Uri uri, string name) - { - var parameters = ParseQueryString(uri); - return parameters[name]; - } + public static Uri SetQueryParameter(this Uri uri, string name, string value) + { + var parameters = ParseQueryString(uri); + parameters[name] = value; + return SetQueryValue(uri, FormatUriParams(parameters)); + } - public static Uri AddQueryParameter(this Uri uri, string name, string value) - { - var queryValue = WebUtility.UrlEncode(name) + "=" + WebUtility.UrlEncode(value); - return AddQueryValue(uri, queryValue); - } + public static Uri SetQueryParameters(this Uri uri, IEnumerable> items) + { + var parameters = ParseQueryString(uri); + foreach (var item in items) + parameters[item.Key] = item.Value; + return SetQueryValue(uri, FormatUriParams(parameters)); + } - public static Uri AddQueryParameters(Uri uri, IEnumerable> parameters) - { - var builder = new StringBuilder(); - foreach (var param in parameters) - { - if (builder.Length > 0) - builder.Append('&'); - builder.Append(Uri.EscapeDataString(param.Key)); - builder.Append('='); - builder.Append(Uri.EscapeDataString(param.Value)); - } - return AddQueryValue(uri, builder.ToString()); - } - - public static Uri SetQueryParameter(this Uri uri, string name, string value) - { - var parameters = ParseQueryString(uri); + public static Uri SetQueryParameters(this Uri uri, params (string name, string? value)[] items) + { + var parameters = ParseQueryString(uri); + foreach (var (name, value) in items) parameters[name] = value; - return SetQueryValue(uri, FormatUriParams(parameters)); - } + return SetQueryValue(uri, FormatUriParams(parameters)); + } - public static Uri SetQueryParameters(this Uri uri, IEnumerable> items) + public static string FormatUriParams(NameValueCollection parameters) + { + if (parameters.Count == 0) + return ""; + + var result = new StringBuilder(); + foreach (var key in parameters.AllKeys) { - var parameters = ParseQueryString(uri); - foreach (var item in items) - parameters[item.Key] = item.Value; - return SetQueryValue(uri, FormatUriParams(parameters)); - } - - public static Uri SetQueryParameters(this Uri uri, params (string name, string? value)[] items) - { - var parameters = ParseQueryString(uri); - foreach (var (name, value) in items) - parameters[name] = value; - return SetQueryValue(uri, FormatUriParams(parameters)); - } - - public static string FormatUriParams(NameValueCollection parameters) - { - if (parameters.Count == 0) - return ""; - - var result = new StringBuilder(); - foreach (var key in parameters.AllKeys) - { - if (string.IsNullOrEmpty(key)) - continue; + if (string.IsNullOrEmpty(key)) + continue; - var value = parameters[key]; - if (value == null) - continue; + var value = parameters[key]; + if (value == null) + continue; - result.AppendFormat("&{0}={1}", Uri.EscapeDataString(key), Uri.EscapeDataString(value)); - } - if (result.Length == 0) - return ""; - return result.ToString(1, result.Length - 1); + result.AppendFormat("&{0}={1}", Uri.EscapeDataString(key), Uri.EscapeDataString(value)); } + if (result.Length == 0) + return ""; + return result.ToString(1, result.Length - 1); + } - private static Uri AddQueryValue(Uri uri, string queryToAppend) + private static Uri AddQueryValue(Uri uri, string queryToAppend) + { + var query = uri.IsAbsoluteUri ? uri.Query : new Uri(FakeHost, uri).Query; + if (query.Length > 1) + query = query[1..] + "&" + queryToAppend; + else + query = queryToAppend; + return SetQueryValue(uri, query); + } + + private static Uri SetQueryValue(Uri uri, string value) + { + var isAbsolute = uri.IsAbsoluteUri; + if (isAbsolute) { - var query = uri.IsAbsoluteUri ? uri.Query : new Uri(FakeHost, uri).Query; - if (query.Length > 1) - query = query[1..] + "&" + queryToAppend; - else - query = queryToAppend; - return SetQueryValue(uri, query); + var builder = new UriBuilder(uri) { Query = value }; + return new Uri(builder.ToString()); } - - private static Uri SetQueryValue(Uri uri, string value) + else { - var isAbsolute = uri.IsAbsoluteUri; - if (isAbsolute) - { - var builder = new UriBuilder(uri) { Query = value }; - return new Uri(builder.ToString()); - } - else - { - var startWithSlash = uri.OriginalString.StartsWith("/"); - uri = new Uri(FakeHost, uri); - var builder = new UriBuilder(uri) { Query = value }; - var additionalStrip = startWithSlash ? 0 : 1; - var newUri = builder.ToString()[(FakeHost.OriginalString.Length + additionalStrip)..]; - return new Uri(newUri, UriKind.Relative); - } + var startWithSlash = uri.OriginalString.StartsWith("/"); + uri = new Uri(FakeHost, uri); + var builder = new UriBuilder(uri) { Query = value }; + var additionalStrip = startWithSlash ? 0 : 1; + var newUri = builder.ToString()[(FakeHost.OriginalString.Length + additionalStrip)..]; + return new Uri(newUri, UriKind.Relative); } } } \ No newline at end of file diff --git a/Clients/CompatApiClient/Utils/Utils.cs b/Clients/CompatApiClient/Utils/Utils.cs index 784f3426..8b456639 100644 --- a/Clients/CompatApiClient/Utils/Utils.cs +++ b/Clients/CompatApiClient/Utils/Utils.cs @@ -1,60 +1,59 @@ using System; -namespace CompatApiClient.Utils +namespace CompatApiClient.Utils; + +public static class Utils { - public static class Utils + private const long UnderKB = 1000; + private const long UnderMB = 1000 * 1024; + private const long UnderGB = 1000 * 1024 * 1024; + + public static string Trim(this string str, int maxLength) { - private const long UnderKB = 1000; - private const long UnderMB = 1000 * 1024; - private const long UnderGB = 1000 * 1024 * 1024; + if (str.Length > maxLength) + return str[..(maxLength - 1)] + "…"; - public static string Trim(this string str, int maxLength) - { - if (str.Length > maxLength) - return str[..(maxLength - 1)] + "…"; - - return str; - } - - public static string Truncate(this string str, int maxLength) - { - if (maxLength < 1) - throw new ArgumentException("Argument must be positive, but was " + maxLength, nameof(maxLength)); - - if (str.Length <= maxLength) - return str; - - return str[..maxLength]; - } - - public static string Sanitize(this string str, bool breakLinks = true, bool replaceBackTicks = false) - { - var result = str.Replace("`", "`\u200d").Replace("@", "@\u200d"); - if (replaceBackTicks) - result = result.Replace('`', '\''); - if (breakLinks) - result = result.Replace(".", ".\u200d").Replace(":", ":\u200d"); - return result; - } - - public static int Clamp(this int amount, int low, int high) - => Math.Min(high, Math.Max(amount, low)); - - public static double Clamp(this double amount, double low, double high) - => Math.Min(high, Math.Max(amount, low)); - - public static string AsStorageUnit(this int bytes) - => AsStorageUnit((long)bytes); - - public static string AsStorageUnit(this long bytes) - { - if (bytes < UnderKB) - return $"{bytes} byte{(bytes == 1 ? "" : "s")}"; - if (bytes < UnderMB) - return $"{bytes / 1024.0:0.##} KB"; - if (bytes < UnderGB) - return $"{bytes / 1024.0 / 1024:0.##} MB"; - return $"{bytes / 1024.0 / 1024 / 1024:0.##} GB"; - } + return str; } -} + + public static string Truncate(this string str, int maxLength) + { + if (maxLength < 1) + throw new ArgumentException("Argument must be positive, but was " + maxLength, nameof(maxLength)); + + if (str.Length <= maxLength) + return str; + + return str[..maxLength]; + } + + public static string Sanitize(this string str, bool breakLinks = true, bool replaceBackTicks = false) + { + var result = str.Replace("`", "`\u200d").Replace("@", "@\u200d"); + if (replaceBackTicks) + result = result.Replace('`', '\''); + if (breakLinks) + result = result.Replace(".", ".\u200d").Replace(":", ":\u200d"); + return result; + } + + public static int Clamp(this int amount, int low, int high) + => Math.Min(high, Math.Max(amount, low)); + + public static double Clamp(this double amount, double low, double high) + => Math.Min(high, Math.Max(amount, low)); + + public static string AsStorageUnit(this int bytes) + => AsStorageUnit((long)bytes); + + public static string AsStorageUnit(this long bytes) + { + if (bytes < UnderKB) + return $"{bytes} byte{(bytes == 1 ? "" : "s")}"; + if (bytes < UnderMB) + return $"{bytes / 1024.0:0.##} KB"; + if (bytes < UnderGB) + return $"{bytes / 1024.0 / 1024:0.##} MB"; + return $"{bytes / 1024.0 / 1024 / 1024:0.##} GB"; + } +} \ No newline at end of file diff --git a/Clients/GithubClient/Client.cs b/Clients/GithubClient/Client.cs index e52943d6..c39b5207 100644 --- a/Clients/GithubClient/Client.cs +++ b/Clients/GithubClient/Client.cs @@ -5,149 +5,147 @@ using System.Threading.Tasks; using CompatApiClient; using Microsoft.Extensions.Caching.Memory; -namespace GithubClient +namespace GithubClient; + +public class Client { - public class Client + private readonly Octokit.GitHubClient client; + + private static readonly TimeSpan PrStatusCacheTime = TimeSpan.FromMinutes(3); + private static readonly TimeSpan IssueStatusCacheTime = TimeSpan.FromMinutes(30); + private static readonly MemoryCache StatusesCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromMinutes(1) }); + private static readonly MemoryCache IssuesCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromMinutes(30) }); + + public static int RateLimit { get; private set; } + public static int RateLimitRemaining { get; private set; } + public static DateTime RateLimitResetTime { get; private set; } + + public Client(string? githubToken) { - private readonly Octokit.GitHubClient client; - - private static readonly TimeSpan PrStatusCacheTime = TimeSpan.FromMinutes(3); - private static readonly TimeSpan IssueStatusCacheTime = TimeSpan.FromMinutes(30); - private static readonly MemoryCache StatusesCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromMinutes(1) }); - private static readonly MemoryCache IssuesCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromMinutes(30) }); - - public static int RateLimit { get; private set; } - public static int RateLimitRemaining { get; private set; } - public static DateTime RateLimitResetTime { get; private set; } - - public Client(string? githubToken) + client = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(ApiConfig.ProductName, ApiConfig.ProductVersion)); + if (!string.IsNullOrEmpty(githubToken)) { - client = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(ApiConfig.ProductName, ApiConfig.ProductVersion)); - if (!string.IsNullOrEmpty(githubToken)) - { - client.Credentials = new Octokit.Credentials(githubToken); - } + client.Credentials = new Octokit.Credentials(githubToken); } - - public async Task GetPrInfoAsync(int pr, CancellationToken cancellationToken) - { - if (StatusesCache.TryGetValue(pr, out Octokit.PullRequest? result)) - { - ApiConfig.Log.Debug($"Returned {nameof(Octokit.PullRequest)} for {pr} from cache"); - return result; - } - - try - { - var request = client.PullRequest.Get("RPCS3", "rpcs3", pr); - request.Wait(cancellationToken); - result = (await request.ConfigureAwait(false)); - UpdateRateLimitStats(); - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - } - if (result == null) - { - ApiConfig.Log.Debug($"Failed to get {nameof(Octokit.PullRequest)}, returning empty result"); - return new(pr); - } - - StatusesCache.Set(pr, result, PrStatusCacheTime); - ApiConfig.Log.Debug($"Cached {nameof(Octokit.PullRequest)} for {pr} for {PrStatusCacheTime}"); - return result; - } - - public async Task GetIssueInfoAsync(int issue, CancellationToken cancellationToken) - { - if (IssuesCache.TryGetValue(issue, out Octokit.Issue? result)) - { - ApiConfig.Log.Debug($"Returned {nameof(Octokit.Issue)} for {issue} from cache"); - return result; - } - - try - { - var request = client.Issue.Get("RPCS3", "rpcs3", issue); - request.Wait(cancellationToken); - result = (await request.ConfigureAwait(false)); - UpdateRateLimitStats(); - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - } - if (result == null) - { - ApiConfig.Log.Debug($"Failed to get {nameof(Octokit.Issue)}, returning empty result"); - return new() { }; - } - - IssuesCache.Set(issue, result, IssueStatusCacheTime); - ApiConfig.Log.Debug($"Cached {nameof(Octokit.Issue)} for {issue} for {IssueStatusCacheTime}"); - return result; - } - - public Task?> GetOpenPrsAsync(CancellationToken cancellationToken) => GetPrsWithStatusAsync(new Octokit.PullRequestRequest - { - State = Octokit.ItemStateFilter.Open - }, cancellationToken); - - public Task?> GetClosedPrsAsync(CancellationToken cancellationToken) => GetPrsWithStatusAsync(new Octokit.PullRequestRequest - { - State = Octokit.ItemStateFilter.Closed, - SortProperty = Octokit.PullRequestSort.Updated, - SortDirection = Octokit.SortDirection.Descending - }, cancellationToken); - - private async Task?> GetPrsWithStatusAsync(Octokit.PullRequestRequest filter, CancellationToken cancellationToken) - { - var statusURI = "https://api.github.com/repos/RPCS3/rpcs3/pulls?state=" + filter.ToString(); - if (StatusesCache.TryGetValue(statusURI, out IReadOnlyList? result)) - { - ApiConfig.Log.Debug("Returned list of opened PRs from cache"); - return result; - } - - try - { - var request = client.PullRequest.GetAllForRepository("RPCS3", "rpcs3", filter); - request.Wait(cancellationToken); - - result = (await request.ConfigureAwait(false)); - UpdateRateLimitStats(); - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - } - if (result != null) - { - StatusesCache.Set(statusURI, result, PrStatusCacheTime); - foreach (var prInfo in result) - StatusesCache.Set(prInfo.Number, prInfo, PrStatusCacheTime); - ApiConfig.Log.Debug($"Cached list of open PRs for {PrStatusCacheTime}"); - } - return result; - } - - private void UpdateRateLimitStats() - { - var apiInfo = client.GetLastApiInfo(); - if (apiInfo == null) - { - return; - } - - RateLimit = apiInfo.RateLimit.Limit; - RateLimitRemaining = apiInfo.RateLimit.Remaining; - RateLimitResetTime = DateTimeOffset.FromUnixTimeSeconds(apiInfo.RateLimit.ResetAsUtcEpochSeconds).UtcDateTime; - - if (RateLimitRemaining < 10) - ApiConfig.Log.Warn($"Github rate limit is low: {RateLimitRemaining} out of {RateLimit}, will be reset on {RateLimitResetTime:u}"); - } - } -} + public async Task GetPrInfoAsync(int pr, CancellationToken cancellationToken) + { + if (StatusesCache.TryGetValue(pr, out Octokit.PullRequest? result)) + { + ApiConfig.Log.Debug($"Returned {nameof(Octokit.PullRequest)} for {pr} from cache"); + return result; + } + + try + { + var request = client.PullRequest.Get("RPCS3", "rpcs3", pr); + request.Wait(cancellationToken); + result = (await request.ConfigureAwait(false)); + UpdateRateLimitStats(); + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + } + if (result == null) + { + ApiConfig.Log.Debug($"Failed to get {nameof(Octokit.PullRequest)}, returning empty result"); + return new(pr); + } + + StatusesCache.Set(pr, result, PrStatusCacheTime); + ApiConfig.Log.Debug($"Cached {nameof(Octokit.PullRequest)} for {pr} for {PrStatusCacheTime}"); + return result; + } + + public async Task GetIssueInfoAsync(int issue, CancellationToken cancellationToken) + { + if (IssuesCache.TryGetValue(issue, out Octokit.Issue? result)) + { + ApiConfig.Log.Debug($"Returned {nameof(Octokit.Issue)} for {issue} from cache"); + return result; + } + + try + { + var request = client.Issue.Get("RPCS3", "rpcs3", issue); + request.Wait(cancellationToken); + result = (await request.ConfigureAwait(false)); + UpdateRateLimitStats(); + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + } + if (result == null) + { + ApiConfig.Log.Debug($"Failed to get {nameof(Octokit.Issue)}, returning empty result"); + return new() { }; + } + + IssuesCache.Set(issue, result, IssueStatusCacheTime); + ApiConfig.Log.Debug($"Cached {nameof(Octokit.Issue)} for {issue} for {IssueStatusCacheTime}"); + return result; + } + + public Task?> GetOpenPrsAsync(CancellationToken cancellationToken) => GetPrsWithStatusAsync(new Octokit.PullRequestRequest + { + State = Octokit.ItemStateFilter.Open + }, cancellationToken); + + public Task?> GetClosedPrsAsync(CancellationToken cancellationToken) => GetPrsWithStatusAsync(new Octokit.PullRequestRequest + { + State = Octokit.ItemStateFilter.Closed, + SortProperty = Octokit.PullRequestSort.Updated, + SortDirection = Octokit.SortDirection.Descending + }, cancellationToken); + + private async Task?> GetPrsWithStatusAsync(Octokit.PullRequestRequest filter, CancellationToken cancellationToken) + { + var statusURI = "https://api.github.com/repos/RPCS3/rpcs3/pulls?state=" + filter.ToString(); + if (StatusesCache.TryGetValue(statusURI, out IReadOnlyList? result)) + { + ApiConfig.Log.Debug("Returned list of opened PRs from cache"); + return result; + } + + try + { + var request = client.PullRequest.GetAllForRepository("RPCS3", "rpcs3", filter); + request.Wait(cancellationToken); + + result = (await request.ConfigureAwait(false)); + UpdateRateLimitStats(); + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + } + if (result != null) + { + StatusesCache.Set(statusURI, result, PrStatusCacheTime); + foreach (var prInfo in result) + StatusesCache.Set(prInfo.Number, prInfo, PrStatusCacheTime); + ApiConfig.Log.Debug($"Cached list of open PRs for {PrStatusCacheTime}"); + } + return result; + } + + private void UpdateRateLimitStats() + { + var apiInfo = client.GetLastApiInfo(); + if (apiInfo == null) + { + return; + } + + RateLimit = apiInfo.RateLimit.Limit; + RateLimitRemaining = apiInfo.RateLimit.Remaining; + RateLimitResetTime = DateTimeOffset.FromUnixTimeSeconds(apiInfo.RateLimit.ResetAsUtcEpochSeconds).UtcDateTime; + + if (RateLimitRemaining < 10) + ApiConfig.Log.Warn($"Github rate limit is low: {RateLimitRemaining} out of {RateLimit}, will be reset on {RateLimitResetTime:u}"); + } + +} \ No newline at end of file diff --git a/Clients/IrdLibraryClient/IrdClient.cs b/Clients/IrdLibraryClient/IrdClient.cs index 2df686d3..09d3a7a9 100644 --- a/Clients/IrdLibraryClient/IrdClient.cs +++ b/Clients/IrdLibraryClient/IrdClient.cs @@ -16,203 +16,202 @@ using HtmlAgilityPack; using IrdLibraryClient.IrdFormat; using IrdLibraryClient.POCOs; -namespace IrdLibraryClient +namespace IrdLibraryClient; + +public class IrdClient { - public class IrdClient + public static readonly string BaseUrl = "https://ps3.aldostools.org"; + + private readonly HttpClient client; + private readonly JsonSerializerOptions jsonOptions; + private static readonly Regex IrdFilename = new(@"ird/(?\w{4}\d{5}-[A-F0-9]+\.ird)", RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase); + + public IrdClient() { - public static readonly string BaseUrl = "https://ps3.aldostools.org"; - - private readonly HttpClient client; - private readonly JsonSerializerOptions jsonOptions; - private static readonly Regex IrdFilename = new(@"ird/(?\w{4}\d{5}-[A-F0-9]+\.ird)", RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase); - - public IrdClient() + client = HttpClientFactory.Create(new CompressionMessageHandler()); + jsonOptions = new JsonSerializerOptions { - client = HttpClientFactory.Create(new CompressionMessageHandler()); - jsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - IncludeFields = true, - }; - } + PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + }; + } - public static string GetDownloadLink(string irdFilename) => $"{BaseUrl}/ird/{irdFilename}"; + public static string GetDownloadLink(string irdFilename) => $"{BaseUrl}/ird/{irdFilename}"; - public async Task SearchAsync(string query, CancellationToken cancellationToken) + public async Task SearchAsync(string query, CancellationToken cancellationToken) + { + query = query.ToUpper(); + try { - query = query.ToUpper(); + var requestUri = new Uri(BaseUrl + "/ird.html"); + using var getMessage = new HttpRequestMessage(HttpMethod.Get, requestUri); + using var response = await client.SendAsync(getMessage, cancellationToken).ConfigureAwait(false); try { - var requestUri = new Uri(BaseUrl + "/ird.html"); - using var getMessage = new HttpRequestMessage(HttpMethod.Get, requestUri); - using var response = await client.SendAsync(getMessage, cancellationToken).ConfigureAwait(false); - try + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + var result = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + HtmlDocument doc = new(); + doc.LoadHtml(result); + return new() { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - var result = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - HtmlDocument doc = new(); - doc.LoadHtml(result); - return new() - { - Data = doc.DocumentNode.Descendants("tr") - .Skip(1) - .Select(tr => tr.Elements("td").ToList()) - .Where(tds => tds.Count > 1 && tds[0].InnerText == query) - .Select(tds => + Data = doc.DocumentNode.Descendants("tr") + .Skip(1) + .Select(tr => tr.Elements("td").ToList()) + .Where(tds => tds.Count > 1 && tds[0].InnerText == query) + .Select(tds => + { + var i = tds.Select(td => td.InnerText.Trim()).ToArray(); + return new SearchResultItem { - var i = tds.Select(td => td.InnerText.Trim()).ToArray(); - return new SearchResultItem - { - Id = i[0], - Title = i[1], - GameVersion = i[2], - UpdateVersion = i[3], - Size = i[4], - FileCount = i[5], - FolderCount = i[6], - MD5 = i[7], - IrdName = i[8], - Filename = i[0] + "-" + i[8] + ".ird", - }; - }) - .ToList(), - }; - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - return null; - } + Id = i[0], + Title = i[1], + GameVersion = i[2], + UpdateVersion = i[3], + Size = i[4], + FileCount = i[5], + FolderCount = i[6], + MD5 = i[7], + IrdName = i[8], + Filename = i[0] + "-" + i[8] + ".ird", + }; + }) + .ToList(), + }; } catch (Exception e) { - ApiConfig.Log.Error(e); + ConsoleLogger.PrintError(e, response); return null; } } - - public async Task> DownloadAsync(string productCode, string localCachePath, CancellationToken cancellationToken) + catch (Exception e) { - var result = new List(); + ApiConfig.Log.Error(e); + return null; + } + } + + public async Task> DownloadAsync(string productCode, string localCachePath, CancellationToken cancellationToken) + { + var result = new List(); + try + { + // first we search local cache and try to load whatever data we can + var localCacheItems = new List(); try { - // first we search local cache and try to load whatever data we can - var localCacheItems = new List(); - try - { - var tmpCacheItemList = Directory.GetFiles(localCachePath, productCode + "*.ird", SearchOption.TopDirectoryOnly) - .Select(Path.GetFileName) - .ToList(); - foreach (var item in tmpCacheItemList) - { - if (string.IsNullOrEmpty(item)) - continue; - - try - { - result.Add(IrdParser.Parse(await File.ReadAllBytesAsync(Path.Combine(localCachePath, item), cancellationToken).ConfigureAwait(false))); - localCacheItems.Add(item); - } - catch (Exception ex) - { - ApiConfig.Log.Warn(ex, "Error reading local IRD file: " + ex.Message); - } - } - } - catch (Exception e) - { - ApiConfig.Log.Warn(e, "Error accessing local IRD cache: " + e.Message); - } - ApiConfig.Log.Debug($"Found {localCacheItems.Count} cached items for {productCode}"); - SearchResult? searchResult = null; - - // then try to do IRD Library search - try - { - searchResult = await SearchAsync(productCode, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - } - var tmpFilesToGet = searchResult?.Data? - .Select(i => i.Filename) - .Except(localCacheItems, StringComparer.InvariantCultureIgnoreCase) + var tmpCacheItemList = Directory.GetFiles(localCachePath, productCode + "*.ird", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) .ToList(); - if (tmpFilesToGet is null or {Count: 0}) - return result; - - // as IRD Library could return more data than we found, try to check for all the items locally - var filesToDownload = new List(); - foreach (var item in tmpFilesToGet) + foreach (var item in tmpCacheItemList) { if (string.IsNullOrEmpty(item)) continue; - + try { - var localItemPath = Path.Combine(localCachePath, item); - if (File.Exists(localItemPath)) - { - result.Add(IrdParser.Parse(await File.ReadAllBytesAsync(localItemPath, cancellationToken).ConfigureAwait(false))); - localCacheItems.Add(item); - } - else - filesToDownload.Add(item); + result.Add(IrdParser.Parse(await File.ReadAllBytesAsync(Path.Combine(localCachePath, item), cancellationToken).ConfigureAwait(false))); + localCacheItems.Add(item); } catch (Exception ex) { ApiConfig.Log.Warn(ex, "Error reading local IRD file: " + ex.Message); - filesToDownload.Add(item); } } - ApiConfig.Log.Debug($"Found {tmpFilesToGet.Count} total matches for {productCode}, {result.Count} already cached"); - if (filesToDownload.Count == 0) - return result; + } + catch (Exception e) + { + ApiConfig.Log.Warn(e, "Error accessing local IRD cache: " + e.Message); + } + ApiConfig.Log.Debug($"Found {localCacheItems.Count} cached items for {productCode}"); + SearchResult? searchResult = null; - // download the remaining .ird files - foreach (var item in filesToDownload) - { - try - { - var resultBytes = await client.GetByteArrayAsync(GetDownloadLink(item), cancellationToken).ConfigureAwait(false); - result.Add(IrdParser.Parse(resultBytes)); - try - { - await File.WriteAllBytesAsync(Path.Combine(localCachePath, item), resultBytes, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - ApiConfig.Log.Warn(ex, $"Failed to write {item} to local cache: {ex.Message}"); - } - } - catch (Exception e) - { - ApiConfig.Log.Warn(e, $"Failed to download {item}: {e.Message}"); - } - } - ApiConfig.Log.Debug($"Returning {result.Count} .ird files for {productCode}"); - return result; + // then try to do IRD Library search + try + { + searchResult = await SearchAsync(productCode, cancellationToken).ConfigureAwait(false); } catch (Exception e) { ApiConfig.Log.Error(e); - return result; } - } + var tmpFilesToGet = searchResult?.Data? + .Select(i => i.Filename) + .Except(localCacheItems, StringComparer.InvariantCultureIgnoreCase) + .ToList(); + if (tmpFilesToGet is null or {Count: 0}) + return result; - private static string? GetTitle(string? html) - { - if (string.IsNullOrEmpty(html)) - return null; - - var idx = html.LastIndexOf("", StringComparison.Ordinal); - var result = html[(idx + 7)..].Trim(); - if (string.IsNullOrEmpty(result)) - return null; + // as IRD Library could return more data than we found, try to check for all the items locally + var filesToDownload = new List(); + foreach (var item in tmpFilesToGet) + { + if (string.IsNullOrEmpty(item)) + continue; + + try + { + var localItemPath = Path.Combine(localCachePath, item); + if (File.Exists(localItemPath)) + { + result.Add(IrdParser.Parse(await File.ReadAllBytesAsync(localItemPath, cancellationToken).ConfigureAwait(false))); + localCacheItems.Add(item); + } + else + filesToDownload.Add(item); + } + catch (Exception ex) + { + ApiConfig.Log.Warn(ex, "Error reading local IRD file: " + ex.Message); + filesToDownload.Add(item); + } + } + ApiConfig.Log.Debug($"Found {tmpFilesToGet.Count} total matches for {productCode}, {result.Count} already cached"); + if (filesToDownload.Count == 0) + return result; + // download the remaining .ird files + foreach (var item in filesToDownload) + { + try + { + var resultBytes = await client.GetByteArrayAsync(GetDownloadLink(item), cancellationToken).ConfigureAwait(false); + result.Add(IrdParser.Parse(resultBytes)); + try + { + await File.WriteAllBytesAsync(Path.Combine(localCachePath, item), resultBytes, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + ApiConfig.Log.Warn(ex, $"Failed to write {item} to local cache: {ex.Message}"); + } + } + catch (Exception e) + { + ApiConfig.Log.Warn(e, $"Failed to download {item}: {e.Message}"); + } + } + ApiConfig.Log.Debug($"Returning {result.Count} .ird files for {productCode}"); return result; } - } -} + catch (Exception e) + { + ApiConfig.Log.Error(e); + return result; + } + } + + private static string? GetTitle(string? html) + { + if (string.IsNullOrEmpty(html)) + return null; + + var idx = html.LastIndexOf("", StringComparison.Ordinal); + var result = html[(idx + 7)..].Trim(); + if (string.IsNullOrEmpty(result)) + return null; + + return result; + } +} \ No newline at end of file diff --git a/Clients/IrdLibraryClient/IrdFormat/Ird.cs b/Clients/IrdLibraryClient/IrdFormat/Ird.cs index 2d8e9919..f9ee0373 100644 --- a/Clients/IrdLibraryClient/IrdFormat/Ird.cs +++ b/Clients/IrdLibraryClient/IrdFormat/Ird.cs @@ -2,43 +2,42 @@ using System.Collections.Generic; using System.Text; -namespace IrdLibraryClient.IrdFormat -{ - public sealed class Ird - { - internal Ird(){} - - public static readonly int Magic = BitConverter.ToInt32(Encoding.ASCII.GetBytes("3IRD"), 0); - public byte Version; - public string ProductCode = null!; // 9 - public byte TitleLength; - public string Title = null!; - public string UpdateVersion = null!; // 4 - public string GameVersion = null!; // 5 - public string AppVersion = null!; // 5 - public int Id; // v7 only? - public int HeaderLength; - public byte[] Header = null!; // gz - public int FooterLength; - public byte[] Footer = null!; // gz - public byte RegionCount; - public List RegionMd5Checksums = null!; // 16 each - public int FileCount; - public List Files = null!; - public int Unknown; // always 0? - public byte[] Pic = null!; // 115, v9 only? - public byte[] Data1 = null!; // 16 - public byte[] Data2 = null!; // 16 - // Pic for RegionMd5Checksums = null!; // 16 each + public int FileCount; + public List Files = null!; + public int Unknown; // always 0? + public byte[] Pic = null!; // 115, v9 only? + public byte[] Data1 = null!; // 16 + public byte[] Data2 = null!; // 16 + // Pic for (result.RegionCount); - for (var i = 0; i < result.RegionCount; i++) - result.RegionMd5Checksums.Add(reader.ReadBytes(16)); - result.FileCount = reader.ReadInt32(); - result.Files = new List(result.FileCount); - for (var i = 0; i < result.FileCount; i++) - { - // ReSharper disable once UseObjectOrCollectionInitializer - var file = new IrdFile(); - file.Offset = reader.ReadInt64(); - file.Md5Checksum = reader.ReadBytes(16); - result.Files.Add(file); - } - result.Unknown = reader.ReadInt32(); - if (result.Version == 9) - result.Pic = reader.ReadBytes(115); - result.Data1 = reader.ReadBytes(16); - result.Data2 = reader.ReadBytes(16); - if (result.Version < 9) - result.Pic = reader.ReadBytes(115); - result.Uid = reader.ReadInt32(); - var dataLength = reader.BaseStream.Position; - result.Crc32 = reader.ReadUInt32(); - var crc32 = Crc32Algorithm.Compute(content, 0, (int)dataLength); - if (result.Crc32 != crc32) - throw new InvalidDataException($"Corrupted IRD data, expected {result.Crc32:x8}, but was {crc32:x8}"); - return result; + using var compressedStream = new MemoryStream(content, false); + using var gzip = new GZipStream(compressedStream, CompressionMode.Decompress); + using var decompressedStream = ApiConfig.MemoryStreamManager.GetStream(); + gzip.CopyTo(decompressedStream); + content = decompressedStream.ToArray(); } + if (BitConverter.ToInt32(content, 0) != Ird.Magic) + throw new FormatException("Not a valid IRD file"); + + var result = new Ird(); + using var stream = new MemoryStream(content, false); + using var reader = new BinaryReader(stream, Encoding.UTF8); + reader.ReadInt32(); // magic + result.Version = reader.ReadByte(); + result.ProductCode = Encoding.ASCII.GetString(reader.ReadBytes(9)); + result.TitleLength = reader.ReadByte(); + result.Title = Encoding.UTF8.GetString(reader.ReadBytes(result.TitleLength)); + result.UpdateVersion = Encoding.ASCII.GetString(reader.ReadBytes(4)).Trim(); + result.GameVersion = Encoding.ASCII.GetString(reader.ReadBytes(5)).Trim(); + result.AppVersion = Encoding.ASCII.GetString(reader.ReadBytes(5)).Trim(); + if (result.Version == 7) + result.Id = reader.ReadInt32(); + result.HeaderLength = reader.ReadInt32(); + result.Header = reader.ReadBytes(result.HeaderLength); + result.FooterLength = reader.ReadInt32(); + result.Footer = reader.ReadBytes(result.FooterLength); + result.RegionCount = reader.ReadByte(); + result.RegionMd5Checksums = new List(result.RegionCount); + for (var i = 0; i < result.RegionCount; i++) + result.RegionMd5Checksums.Add(reader.ReadBytes(16)); + result.FileCount = reader.ReadInt32(); + result.Files = new List(result.FileCount); + for (var i = 0; i < result.FileCount; i++) + { + // ReSharper disable once UseObjectOrCollectionInitializer + var file = new IrdFile(); + file.Offset = reader.ReadInt64(); + file.Md5Checksum = reader.ReadBytes(16); + result.Files.Add(file); + } + result.Unknown = reader.ReadInt32(); + if (result.Version == 9) + result.Pic = reader.ReadBytes(115); + result.Data1 = reader.ReadBytes(16); + result.Data2 = reader.ReadBytes(16); + if (result.Version < 9) + result.Pic = reader.ReadBytes(115); + result.Uid = reader.ReadInt32(); + var dataLength = reader.BaseStream.Position; + result.Crc32 = reader.ReadUInt32(); + var crc32 = Crc32Algorithm.Compute(content, 0, (int)dataLength); + if (result.Crc32 != crc32) + throw new InvalidDataException($"Corrupted IRD data, expected {result.Crc32:x8}, but was {crc32:x8}"); + return result; } -} +} \ No newline at end of file diff --git a/Clients/IrdLibraryClient/IrdFormat/IsoHeaderParser.cs b/Clients/IrdLibraryClient/IrdFormat/IsoHeaderParser.cs index 71312b3e..0825090d 100644 --- a/Clients/IrdLibraryClient/IrdFormat/IsoHeaderParser.cs +++ b/Clients/IrdLibraryClient/IrdFormat/IsoHeaderParser.cs @@ -5,28 +5,27 @@ using System.Linq; using CompatApiClient; using DiscUtils.Iso9660; -namespace IrdLibraryClient.IrdFormat -{ - public static class IsoHeaderParser - { - public static List GetFilenames(this Ird ird) - { - using var decompressedStream = ApiConfig.MemoryStreamManager.GetStream(); - using (var compressedStream = new MemoryStream(ird.Header, false)) - { - using var gzip = new GZipStream(compressedStream, CompressionMode.Decompress); - gzip.CopyTo(decompressedStream); - } +namespace IrdLibraryClient.IrdFormat; - decompressedStream.Seek(0, SeekOrigin.Begin); - var reader = new CDReader(decompressedStream, true, true); - return reader.GetFiles(reader.Root.FullName, "*.*", SearchOption.AllDirectories) - .Distinct() - .Select(n => n - .TrimStart('\\') - .Replace('\\', '/') - .TrimEnd('.') - ).ToList(); +public static class IsoHeaderParser +{ + public static List GetFilenames(this Ird ird) + { + using var decompressedStream = ApiConfig.MemoryStreamManager.GetStream(); + using (var compressedStream = new MemoryStream(ird.Header, false)) + { + using var gzip = new GZipStream(compressedStream, CompressionMode.Decompress); + gzip.CopyTo(decompressedStream); } + + decompressedStream.Seek(0, SeekOrigin.Begin); + var reader = new CDReader(decompressedStream, true, true); + return reader.GetFiles(reader.Root.FullName, "*.*", SearchOption.AllDirectories) + .Distinct() + .Select(n => n + .TrimStart('\\') + .Replace('\\', '/') + .TrimEnd('.') + ).ToList(); } } \ No newline at end of file diff --git a/Clients/IrdLibraryClient/POCOs/SearchResult.cs b/Clients/IrdLibraryClient/POCOs/SearchResult.cs index 43f9db55..ad02a8f9 100644 --- a/Clients/IrdLibraryClient/POCOs/SearchResult.cs +++ b/Clients/IrdLibraryClient/POCOs/SearchResult.cs @@ -1,25 +1,24 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace IrdLibraryClient.POCOs +namespace IrdLibraryClient.POCOs; + +public sealed class SearchResult { - public sealed class SearchResult - { - public List? Data; - } - - public sealed class SearchResultItem - { - public string? Id; // product code - public string? Title; - public string? GameVersion; - public string? UpdateVersion; - public string? Size; - public string? FileCount; - public string? FolderCount; - public string? MD5; - public string? IrdName; - - public string? Filename; - } + public List? Data; } + +public sealed class SearchResultItem +{ + public string? Id; // product code + public string? Title; + public string? GameVersion; + public string? UpdateVersion; + public string? Size; + public string? FileCount; + public string? FolderCount; + public string? MD5; + public string? IrdName; + + public string? Filename; +} \ No newline at end of file diff --git a/Clients/MediafireClient/Client.cs b/Clients/MediafireClient/Client.cs index 62cff24e..4d1d5b6e 100644 --- a/Clients/MediafireClient/Client.cs +++ b/Clients/MediafireClient/Client.cs @@ -15,86 +15,85 @@ using System.Text.RegularExpressions; using CompatApiClient.Formatters; using MediafireClient.POCOs; -namespace MediafireClient +namespace MediafireClient; + +public sealed class Client { - public sealed class Client - { - private readonly HttpClient client; - private readonly JsonSerializerOptions jsonOptions; + private readonly HttpClient client; + private readonly JsonSerializerOptions jsonOptions; - //var optSecurityToken = "1605819132.376f3d84695f46daa7b69ee67fbc5edb0a00843a8b2d5ac7d3d1b1ad8a4212b0"; - //private static readonly Regex SecurityTokenRegex = new(@"(var\s+optSecurityToken|name=""security"" value)\s*=\s*""(?.+)""", RegexOptions.ExplicitCapture); - //var optDirectURL = "https://download1499.mediafire.com/12zqzob7gbfg/tmybrjpmtrpcejl/DemonsSouls_CrashLog_Nov.19th.zip"; - private static readonly Regex DirectUrlRegex = new(@"(var\s+optDirectURL|href)\s*=\s*""(?https?://download\d+\.mediafire\.com/.+)"""); + //var optSecurityToken = "1605819132.376f3d84695f46daa7b69ee67fbc5edb0a00843a8b2d5ac7d3d1b1ad8a4212b0"; + //private static readonly Regex SecurityTokenRegex = new(@"(var\s+optSecurityToken|name=""security"" value)\s*=\s*""(?.+)""", RegexOptions.ExplicitCapture); + //var optDirectURL = "https://download1499.mediafire.com/12zqzob7gbfg/tmybrjpmtrpcejl/DemonsSouls_CrashLog_Nov.19th.zip"; + private static readonly Regex DirectUrlRegex = new(@"(var\s+optDirectURL|href)\s*=\s*""(?https?://download\d+\.mediafire\.com/.+)"""); - public Client() + public Client() + { + client = HttpClientFactory.Create(new CompressionMessageHandler()); + jsonOptions = new() { - client = HttpClientFactory.Create(new CompressionMessageHandler()); - jsonOptions = new() - { - PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - IncludeFields = true, - }; - } + PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + }; + } - public async Task GetWebLinkAsync(string quickKey, CancellationToken cancellationToken) + public async Task GetWebLinkAsync(string quickKey, CancellationToken cancellationToken) + { + try { + var uri = new Uri($"https://www.mediafire.com/api/1.5/file/get_links.php?quick_key={quickKey}&response_format=json"); + using var message = new HttpRequestMessage(HttpMethod.Get, uri); + message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); try { - var uri = new Uri($"https://www.mediafire.com/api/1.5/file/get_links.php?quick_key={quickKey}&response_format=json"); - using var message = new HttpRequestMessage(HttpMethod.Get, uri); - message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - try - { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - } + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + return await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception e) { - ApiConfig.Log.Error(e); + ConsoleLogger.PrintError(e, response); } - return null; } - - public async Task GetDirectDownloadLinkAsync(Uri webLink, CancellationToken cancellationToken) + catch (Exception e) { - try + ApiConfig.Log.Error(e); + } + return null; + } + + public async Task GetDirectDownloadLinkAsync(Uri webLink, CancellationToken cancellationToken) + { + try + { + using var message = new HttpRequestMessage(HttpMethod.Get, webLink); + message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + if (response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.TemporaryRedirect) { - using var message = new HttpRequestMessage(HttpMethod.Get, webLink); - message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - if (response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.TemporaryRedirect) - { - var newLocation = response.Headers.Location; - ApiConfig.Log.Warn($"Unexpected redirect from {webLink} to {newLocation}"); - return null; - } + var newLocation = response.Headers.Location; + ApiConfig.Log.Warn($"Unexpected redirect from {webLink} to {newLocation}"); + return null; + } - try - { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var m = DirectUrlRegex.Match(html); - if (m.Success) - return new(m.Groups["direct_link"].Value); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - } + try + { + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var m = DirectUrlRegex.Match(html); + if (m.Success) + return new(m.Groups["direct_link"].Value); } catch (Exception e) { - ApiConfig.Log.Error(e); + ConsoleLogger.PrintError(e, response); } - return null; } + catch (Exception e) + { + ApiConfig.Log.Error(e); + } + return null; } } \ No newline at end of file diff --git a/Clients/MediafireClient/POCOs/LinksResult.cs b/Clients/MediafireClient/POCOs/LinksResult.cs index 68488f22..6c687304 100644 --- a/Clients/MediafireClient/POCOs/LinksResult.cs +++ b/Clients/MediafireClient/POCOs/LinksResult.cs @@ -1,26 +1,24 @@ -namespace MediafireClient.POCOs +namespace MediafireClient.POCOs; +#nullable disable + +public sealed class LinksResult { - #nullable disable - - public sealed class LinksResult - { - public LinksResponse Response; - } + public LinksResponse Response; +} - public sealed class LinksResponse - { - public string Action; - public string Result; - public string CurrentApiVersion; - public Link[] Links; - } +public sealed class LinksResponse +{ + public string Action; + public string Result; + public string CurrentApiVersion; + public Link[] Links; +} - public sealed class Link - { - public string Quickkey; - public string NormalDownload; - public string DirectDownload; - } +public sealed class Link +{ + public string Quickkey; + public string NormalDownload; + public string DirectDownload; +} - #nullable restore -} \ No newline at end of file +#nullable restore \ No newline at end of file diff --git a/Clients/OneDriveClient/Client.cs b/Clients/OneDriveClient/Client.cs index 8ae4bb90..01c07bcd 100644 --- a/Clients/OneDriveClient/Client.cs +++ b/Clients/OneDriveClient/Client.cs @@ -10,99 +10,98 @@ using System.Text.Json; using System.Text.Json.Serialization; using OneDriveClient.POCOs; -namespace OneDriveClient +namespace OneDriveClient; + +public class Client { - public class Client + private readonly HttpClient client; + private readonly HttpClient noRedirectsClient; + private readonly JsonSerializerOptions jsonOptions; + + public Client() { - private readonly HttpClient client; - private readonly HttpClient noRedirectsClient; - private readonly JsonSerializerOptions jsonOptions; - - public Client() + client = HttpClientFactory.Create(new CompressionMessageHandler()); + noRedirectsClient = HttpClientFactory.Create(new HttpClientHandler {AllowAutoRedirect = false}); + jsonOptions = new JsonSerializerOptions { - client = HttpClientFactory.Create(new CompressionMessageHandler()); - noRedirectsClient = HttpClientFactory.Create(new HttpClientHandler {AllowAutoRedirect = false}); - jsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - IncludeFields = true, - }; - } - - private async Task ResolveShortLink(Uri shortLink, CancellationToken cancellationToken) - { - try - { - using var message = new HttpRequestMessage(HttpMethod.Head, shortLink); - message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); - using var response = await noRedirectsClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - return response.Headers.Location; - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - } - return null; - } - - // https://1drv.ms/u/s!AruI8iDXabVJ1ShAMIqxgU2tiHZ3 redirects to https://onedrive.live.com/redir?resid=49B569D720F288BB!10920&authkey=!AEAwirGBTa2Idnc - // https://onedrive.live.com/?authkey=!AEAwirGBTa2Idnc&cid=49B569D720F288BB&id=49B569D720F288BB!10920&parId=49B569D720F288BB!4371&o=OneUp - public async Task ResolveContentLinkAsync(Uri? shareLink, CancellationToken cancellationToken) - { - if (shareLink?.Host == "1drv.ms") - shareLink = await ResolveShortLink(shareLink, cancellationToken).ConfigureAwait(false); - if (shareLink is null) - return null; - - var queryParams = shareLink.ParseQueryString(); - string resourceId, authKey; - if (queryParams["resid"] is string resId && queryParams["authkey"] is string akey) - { - resourceId = resId; - authKey = akey; - } - else if (queryParams["id"] is string rid && queryParams["authkey"] is string aukey) - { - resourceId = rid; - authKey = aukey; - } - else - { - ApiConfig.Log.Warn("Unknown or invalid OneDrive resource link: " + shareLink); - return null; - } - - var itemId = resourceId.Split('!')[0]; - try - { - var resourceMetaUri = new Uri($"https://api.onedrive.com/v1.0/drives/{itemId}/items/{resourceId}") - .SetQueryParameters( - ("authkey", authKey), - ("select", "id,@content.downloadUrl,name,size") - ); - using var message = new HttpRequestMessage(HttpMethod.Get, resourceMetaUri); - message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); - using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); - try - { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - var meta = await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); - if (meta?.ContentDownloadUrl is null) - throw new InvalidOperationException("Failed to properly deserialize response body"); - - return meta; - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - } - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - } - return null; - } + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + }; } -} + + private async Task ResolveShortLink(Uri shortLink, CancellationToken cancellationToken) + { + try + { + using var message = new HttpRequestMessage(HttpMethod.Head, shortLink); + message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); + using var response = await noRedirectsClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + return response.Headers.Location; + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + } + return null; + } + + // https://1drv.ms/u/s!AruI8iDXabVJ1ShAMIqxgU2tiHZ3 redirects to https://onedrive.live.com/redir?resid=49B569D720F288BB!10920&authkey=!AEAwirGBTa2Idnc + // https://onedrive.live.com/?authkey=!AEAwirGBTa2Idnc&cid=49B569D720F288BB&id=49B569D720F288BB!10920&parId=49B569D720F288BB!4371&o=OneUp + public async Task ResolveContentLinkAsync(Uri? shareLink, CancellationToken cancellationToken) + { + if (shareLink?.Host == "1drv.ms") + shareLink = await ResolveShortLink(shareLink, cancellationToken).ConfigureAwait(false); + if (shareLink is null) + return null; + + var queryParams = shareLink.ParseQueryString(); + string resourceId, authKey; + if (queryParams["resid"] is string resId && queryParams["authkey"] is string akey) + { + resourceId = resId; + authKey = akey; + } + else if (queryParams["id"] is string rid && queryParams["authkey"] is string aukey) + { + resourceId = rid; + authKey = aukey; + } + else + { + ApiConfig.Log.Warn("Unknown or invalid OneDrive resource link: " + shareLink); + return null; + } + + var itemId = resourceId.Split('!')[0]; + try + { + var resourceMetaUri = new Uri($"https://api.onedrive.com/v1.0/drives/{itemId}/items/{resourceId}") + .SetQueryParameters( + ("authkey", authKey), + ("select", "id,@content.downloadUrl,name,size") + ); + using var message = new HttpRequestMessage(HttpMethod.Get, resourceMetaUri); + message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); + using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + try + { + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + var meta = await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); + if (meta?.ContentDownloadUrl is null) + throw new InvalidOperationException("Failed to properly deserialize response body"); + + return meta; + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response); + } + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + } + return null; + } +} \ No newline at end of file diff --git a/Clients/OneDriveClient/POCOs/DriveItemMeta.cs b/Clients/OneDriveClient/POCOs/DriveItemMeta.cs index e5cb16e1..bc47047a 100644 --- a/Clients/OneDriveClient/POCOs/DriveItemMeta.cs +++ b/Clients/OneDriveClient/POCOs/DriveItemMeta.cs @@ -1,15 +1,14 @@ using System.Text.Json.Serialization; -namespace OneDriveClient.POCOs +namespace OneDriveClient.POCOs; + +public sealed class DriveItemMeta { - public sealed class DriveItemMeta - { - public string? Id; - public string? Name; - public int Size; - [JsonPropertyName("@odata.context")] - public string? OdataContext; - [JsonPropertyName("@content.downloadUrl")] - public string? ContentDownloadUrl; - } -} + public string? Id; + public string? Name; + public int Size; + [JsonPropertyName("@odata.context")] + public string? OdataContext; + [JsonPropertyName("@content.downloadUrl")] + public string? ContentDownloadUrl; +} \ No newline at end of file diff --git a/Clients/PsnClient/CustomTlsCertificatesHandler.cs b/Clients/PsnClient/CustomTlsCertificatesHandler.cs index 0245e43a..92b4ceed 100644 --- a/Clients/PsnClient/CustomTlsCertificatesHandler.cs +++ b/Clients/PsnClient/CustomTlsCertificatesHandler.cs @@ -7,104 +7,103 @@ using System.Reflection; using System.Security.Cryptography.X509Certificates; using CompatApiClient; -namespace PsnClient -{ - public class CustomTlsCertificatesHandler: HttpClientHandler - { - private readonly Func? defaultCertHandler; - private static readonly X509CertificateCollection CustomCaCollection = new X509Certificate2Collection(); - private static readonly ConcurrentDictionary ValidationCache = new(1, 5); +namespace PsnClient; - static CustomTlsCertificatesHandler() +public class CustomTlsCertificatesHandler: HttpClientHandler +{ + private readonly Func? defaultCertHandler; + private static readonly X509CertificateCollection CustomCaCollection = new X509Certificate2Collection(); + private static readonly ConcurrentDictionary ValidationCache = new(1, 5); + + static CustomTlsCertificatesHandler() + { + var importedCAs = false; + try { - var importedCAs = false; + var current = Assembly.GetExecutingAssembly(); + var certNames = current.GetManifestResourceNames().Where(cn => cn.ToUpperInvariant().EndsWith(".CER")).ToList(); + if (certNames.Count == 0) + { + ApiConfig.Log.Warn("No embedded Sony root CA certificates were found"); + return; + } + + foreach (var resource in certNames) + { + using var stream = current.GetManifestResourceStream(resource); + using var memStream = ApiConfig.MemoryStreamManager.GetStream(); + stream?.CopyTo(memStream); + var certData = memStream.ToArray(); + if (certData.Length == 0) + continue; + + var cert = new X509Certificate2(certData); + var cn = cert.GetNameInfo(X509NameType.SimpleName, false); + if (!cn.StartsWith("SCEI DNAS Root")) + continue; + + CustomCaCollection.Add(cert); + ApiConfig.Log.Debug($"Using Sony root CA with CN '{cn}' for custom certificate validation"); + importedCAs = true; + } + } + catch (Exception e) + { + ApiConfig.Log.Error(e, $"Failed to import Sony root CA certificates"); + } + finally + { + if (importedCAs) + ApiConfig.Log.Info("Configured custom Sony root CA certificates"); + } + } + + public CustomTlsCertificatesHandler() + { + defaultCertHandler = ServerCertificateCustomValidationCallback; + ServerCertificateCustomValidationCallback = IgnoreSonyRootCertificates; + } + + private bool IgnoreSonyRootCertificates(HttpRequestMessage requestMessage, X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors policyErrors) + { + var issuer = certificate?.GetNameInfo(X509NameType.SimpleName, true) ?? "unknown issuer"; + if (issuer.StartsWith("SCEI DNAS Root 0")) + { + var thumbprint = certificate!.GetCertHashString(); + if (ValidationCache.TryGetValue(thumbprint, out var result)) + return result; + + result = false; try { - var current = Assembly.GetExecutingAssembly(); - var certNames = current.GetManifestResourceNames().Where(cn => cn.ToUpperInvariant().EndsWith(".CER")).ToList(); - if (certNames.Count == 0) + using var customChain = new X509Chain(false); + var policy = customChain.ChainPolicy; + policy.ExtraStore.AddRange(CustomCaCollection); + policy.RevocationMode = X509RevocationMode.NoCheck; + if (customChain.Build(certificate) && customChain.ChainStatus.All(s => s.Status == X509ChainStatusFlags.NoError)) { - ApiConfig.Log.Warn("No embedded Sony root CA certificates were found"); - return; + ApiConfig.Log.Debug($"Successfully validated certificate {thumbprint} for {requestMessage.RequestUri?.Host}"); + result = true; } - - foreach (var resource in certNames) + if (!result) + result = customChain.ChainStatus.All(s => s.Status == X509ChainStatusFlags.UntrustedRoot); + if (!result) { - using var stream = current.GetManifestResourceStream(resource); - using var memStream = ApiConfig.MemoryStreamManager.GetStream(); - stream?.CopyTo(memStream); - var certData = memStream.ToArray(); - if (certData.Length == 0) - continue; - - var cert = new X509Certificate2(certData); - var cn = cert.GetNameInfo(X509NameType.SimpleName, false); - if (!cn.StartsWith("SCEI DNAS Root")) - continue; - - CustomCaCollection.Add(cert); - ApiConfig.Log.Debug($"Using Sony root CA with CN '{cn}' for custom certificate validation"); - importedCAs = true; + ApiConfig.Log.Warn($"Failed to validate certificate {thumbprint} for {requestMessage.RequestUri?.Host}"); + foreach (var s in customChain.ChainStatus) + ApiConfig.Log.Debug($"{s.Status}: {s.StatusInformation}"); } + ValidationCache[thumbprint] = result; } catch (Exception e) { - ApiConfig.Log.Error(e, $"Failed to import Sony root CA certificates"); - } - finally - { - if (importedCAs) - ApiConfig.Log.Info("Configured custom Sony root CA certificates"); + ApiConfig.Log.Error(e, $"Failed to validate certificate {thumbprint} for {requestMessage.RequestUri?.Host}"); } + return result; } - - public CustomTlsCertificatesHandler() - { - defaultCertHandler = ServerCertificateCustomValidationCallback; - ServerCertificateCustomValidationCallback = IgnoreSonyRootCertificates; - } - - private bool IgnoreSonyRootCertificates(HttpRequestMessage requestMessage, X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors policyErrors) - { - var issuer = certificate?.GetNameInfo(X509NameType.SimpleName, true) ?? "unknown issuer"; - if (issuer.StartsWith("SCEI DNAS Root 0")) - { - var thumbprint = certificate!.GetCertHashString(); - if (ValidationCache.TryGetValue(thumbprint, out var result)) - return result; - - result = false; - try - { - using var customChain = new X509Chain(false); - var policy = customChain.ChainPolicy; - policy.ExtraStore.AddRange(CustomCaCollection); - policy.RevocationMode = X509RevocationMode.NoCheck; - if (customChain.Build(certificate) && customChain.ChainStatus.All(s => s.Status == X509ChainStatusFlags.NoError)) - { - ApiConfig.Log.Debug($"Successfully validated certificate {thumbprint} for {requestMessage.RequestUri?.Host}"); - result = true; - } - if (!result) - result = customChain.ChainStatus.All(s => s.Status == X509ChainStatusFlags.UntrustedRoot); - if (!result) - { - ApiConfig.Log.Warn($"Failed to validate certificate {thumbprint} for {requestMessage.RequestUri?.Host}"); - foreach (var s in customChain.ChainStatus) - ApiConfig.Log.Debug($"{s.Status}: {s.StatusInformation}"); - } - ValidationCache[thumbprint] = result; - } - catch (Exception e) - { - ApiConfig.Log.Error(e, $"Failed to validate certificate {thumbprint} for {requestMessage.RequestUri?.Host}"); - } - return result; - } #if DEBUG - ApiConfig.Log.Debug("Using default certificate validation handler for " + issuer); + ApiConfig.Log.Debug("Using default certificate validation handler for " + issuer); #endif - return defaultCertHandler?.Invoke(requestMessage, certificate, chain, policyErrors) ?? true; - } + return defaultCertHandler?.Invoke(requestMessage, certificate, chain, policyErrors) ?? true; } } \ No newline at end of file diff --git a/Clients/PsnClient/POCOs/App.cs b/Clients/PsnClient/POCOs/App.cs index 4b9e98cb..320748a4 100644 --- a/Clients/PsnClient/POCOs/App.cs +++ b/Clients/PsnClient/POCOs/App.cs @@ -1,25 +1,23 @@ -namespace PsnClient.POCOs +namespace PsnClient.POCOs; +// https://transact.playstation.com/assets/app.json +// returns an array of different objects +// api endpoints, oauth, oauth authorize, telemetry, localization options, billing template, locales, country names, topup settings, paypal sandbox settings, gct, apm, sofort, ... + +// this is item #6 in App array +public sealed class AppLocales { - // https://transact.playstation.com/assets/app.json - // returns an array of different objects - // api endpoints, oauth, oauth authorize, telemetry, localization options, billing template, locales, country names, topup settings, paypal sandbox settings, gct, apm, sofort, ... - - // this is item #6 in App array - public sealed class AppLocales - { - public string[]? EnabledLocales; // "ar-AE",... - public AppLocaleOverride[]? Overrides; - } - - public sealed class AppLocaleOverride - { - public AppLocaleOverrideCriteria? Criteria; - public string? GensenLocale; // "ar-AE" - } - - public sealed class AppLocaleOverrideCriteria - { - public string? Language; // "ar" - public string? Country; // "AE|BH|KW|LB|OM|QA|SA" - } + public string[]? EnabledLocales; // "ar-AE",... + public AppLocaleOverride[]? Overrides; } + +public sealed class AppLocaleOverride +{ + public AppLocaleOverrideCriteria? Criteria; + public string? GensenLocale; // "ar-AE" +} + +public sealed class AppLocaleOverrideCriteria +{ + public string? Language; // "ar" + public string? Country; // "AE|BH|KW|LB|OM|QA|SA" +} \ No newline at end of file diff --git a/Clients/PsnClient/POCOs/Container.cs b/Clients/PsnClient/POCOs/Container.cs index 6645976e..f4d2b048 100644 --- a/Clients/PsnClient/POCOs/Container.cs +++ b/Clients/PsnClient/POCOs/Container.cs @@ -1,204 +1,202 @@ using System; -namespace PsnClient.POCOs -{ +namespace PsnClient.POCOs; #nullable disable - public sealed class Container - { - public ContainerData Data; - public ContainerIncluded[] Included; - } - - public sealed class ContainerData - { - public string Id; - public string Type; - public ContainerDataAttributes Attributes; - public Relationships Relationships; - } - - public sealed class ContainerDataAttributes - { - public string Name; - public bool? NsxPsPlusUpsell; - public int? TemplateId; - public string ThumbnailUrlBase; - public int? Start; - public int? Size; - public int TotalResults; - public string Query; - public ContainerBanner[] Banners; - public ContainerFacet[] Facets; - public ContainerPromoBackground[] PromoBackgrounds; - public ContainerDataAttributesSubScenes SubScenes; - } - - public sealed class ContainerFacet - { - public string Name; - public ContainerFacetItem[] Items; - } - - public sealed class ContainerFacetItem - { - public string Key; - public string Name; - public int Count; - } - - public sealed class ContainerBanner { } - public sealed class ContainerPromoBackground { } - public sealed class ContainerDataAttributesSubScenes { } - - public sealed class ContainerIncluded - { - public string Id; - public string Type; - public ContainerIncludedAttributes Attributes; - public Relationships Relationships; - } - - public sealed class ContainerIncludedAttributes - { - public string ContentType; // "1" - public string DefaultSkuId; - public bool DobRequired; - public GameFileSize FileSize; - public string GameContentType; // "Bundle" - public string[] Genres; - public bool? IsIgcUpsell; - public bool? IsMultiplayerUpsell; - public string KamajiRelationship; // "bundles" - public string LegalText; - public string LongDescription; - public string MacrossBrainContext; // "game" - public GameMediaList MediaList; - public string Name; - public GameParent Parent; - public string[] Platforms; // "PS4" - public string PrimaryClassification; // "PREMIUM_GAME" - public string SecondaryClassification; // "GAME" - public string TertiaryClassification; // "BUNDLE" - public string ProviderName; // "EA Swiss Sarl" - public string PsCameraCompatibility; // "incompatible" - public string PsMoveCompatibility; // "incompatible" - public string PsVrCompatibility; // "incompatible" - public DateTime? ReleaseDate; // "2019-02-22T00:00:00Z" - public GameSku[] Skus; - public GameStarRating StarRating; - public GameLanguageCode[] SubtitleLanguageCodes; - public GameLanguageCode[] VoiceLanguageCodes; - public string ThumbnailUrlBase; - public string TopCategory; // "downloadable_game" - public GameUpsellInfo UpsellInfo; - // legacy-sku - public GameSkuRelation[] Eligibilities; - public GameSkuRelation[] Entitlements; - } - - public sealed class GameFileSize - { - public string Unit; - public decimal? Value; - } - - public sealed class GameMediaList - { - public GameMediaPreview[] Preview; - public GameMediaPromo Promo; - public GameMediaLink[] Screenshots; - } - - public sealed class GameMediaPreview { } - - public sealed class GameMediaPromo - { - public GameMediaLink[] Images; - public GameMediaLink[] Videos; - } - - public sealed class GameMediaLink - { - public string Url; - } - - public sealed class GameParent - { - public string Id; - public string Name; - public string Thumbnail; - public string Url; - } - - public sealed class GameSku - { - public string Id; - public string Name; - public bool IsPreorder; - public bool? Multibuy; - public DateTime? PlayabilityDate; - public GameSkuPrices Prices; - } - - public sealed class GameSkuPrices - { - public GameSkuPricesInfo NonPlusUser; - public GameSkuPricesInfo PlusUser; - } - - public sealed class GameSkuPricesInfo - { - public GamePriceInfo ActualPrice; - public GamePriceAvailability Availability; - public decimal DiscountPercentage; - public bool IsPlus; - public GamePriceInfo StrikeThroughPrice; - public GamePriceInfo UpsellPrice; - } - - public sealed class GamePriceInfo - { - public string Display; - public decimal Value; - } - - public sealed class GamePriceAvailability - { - public DateTime? StartDate; - public DateTime? EndDate; - } - - public sealed class GameStarRating - { - public decimal Score; - public int Total; - } - - public sealed class GameLanguageCode - { - public string Name; - public string[] Codes; - } - - public sealed class GameUpsellInfo - { - public string Type; - public string DisplayPrice; - public bool IsFree; - public decimal DiscountPercentageDifference; - } - - public sealed class GameSkuRelation - { - public string Id; - public string Name; - } - - public sealed class FirmwareInfo - { - public string Version; - public string DownloadUrl; - public string Locale; - } - #nullable restore +public sealed class Container +{ + public ContainerData Data; + public ContainerIncluded[] Included; } + +public sealed class ContainerData +{ + public string Id; + public string Type; + public ContainerDataAttributes Attributes; + public Relationships Relationships; +} + +public sealed class ContainerDataAttributes +{ + public string Name; + public bool? NsxPsPlusUpsell; + public int? TemplateId; + public string ThumbnailUrlBase; + public int? Start; + public int? Size; + public int TotalResults; + public string Query; + public ContainerBanner[] Banners; + public ContainerFacet[] Facets; + public ContainerPromoBackground[] PromoBackgrounds; + public ContainerDataAttributesSubScenes SubScenes; +} + +public sealed class ContainerFacet +{ + public string Name; + public ContainerFacetItem[] Items; +} + +public sealed class ContainerFacetItem +{ + public string Key; + public string Name; + public int Count; +} + +public sealed class ContainerBanner { } +public sealed class ContainerPromoBackground { } +public sealed class ContainerDataAttributesSubScenes { } + +public sealed class ContainerIncluded +{ + public string Id; + public string Type; + public ContainerIncludedAttributes Attributes; + public Relationships Relationships; +} + +public sealed class ContainerIncludedAttributes +{ + public string ContentType; // "1" + public string DefaultSkuId; + public bool DobRequired; + public GameFileSize FileSize; + public string GameContentType; // "Bundle" + public string[] Genres; + public bool? IsIgcUpsell; + public bool? IsMultiplayerUpsell; + public string KamajiRelationship; // "bundles" + public string LegalText; + public string LongDescription; + public string MacrossBrainContext; // "game" + public GameMediaList MediaList; + public string Name; + public GameParent Parent; + public string[] Platforms; // "PS4" + public string PrimaryClassification; // "PREMIUM_GAME" + public string SecondaryClassification; // "GAME" + public string TertiaryClassification; // "BUNDLE" + public string ProviderName; // "EA Swiss Sarl" + public string PsCameraCompatibility; // "incompatible" + public string PsMoveCompatibility; // "incompatible" + public string PsVrCompatibility; // "incompatible" + public DateTime? ReleaseDate; // "2019-02-22T00:00:00Z" + public GameSku[] Skus; + public GameStarRating StarRating; + public GameLanguageCode[] SubtitleLanguageCodes; + public GameLanguageCode[] VoiceLanguageCodes; + public string ThumbnailUrlBase; + public string TopCategory; // "downloadable_game" + public GameUpsellInfo UpsellInfo; + // legacy-sku + public GameSkuRelation[] Eligibilities; + public GameSkuRelation[] Entitlements; +} + +public sealed class GameFileSize +{ + public string Unit; + public decimal? Value; +} + +public sealed class GameMediaList +{ + public GameMediaPreview[] Preview; + public GameMediaPromo Promo; + public GameMediaLink[] Screenshots; +} + +public sealed class GameMediaPreview { } + +public sealed class GameMediaPromo +{ + public GameMediaLink[] Images; + public GameMediaLink[] Videos; +} + +public sealed class GameMediaLink +{ + public string Url; +} + +public sealed class GameParent +{ + public string Id; + public string Name; + public string Thumbnail; + public string Url; +} + +public sealed class GameSku +{ + public string Id; + public string Name; + public bool IsPreorder; + public bool? Multibuy; + public DateTime? PlayabilityDate; + public GameSkuPrices Prices; +} + +public sealed class GameSkuPrices +{ + public GameSkuPricesInfo NonPlusUser; + public GameSkuPricesInfo PlusUser; +} + +public sealed class GameSkuPricesInfo +{ + public GamePriceInfo ActualPrice; + public GamePriceAvailability Availability; + public decimal DiscountPercentage; + public bool IsPlus; + public GamePriceInfo StrikeThroughPrice; + public GamePriceInfo UpsellPrice; +} + +public sealed class GamePriceInfo +{ + public string Display; + public decimal Value; +} + +public sealed class GamePriceAvailability +{ + public DateTime? StartDate; + public DateTime? EndDate; +} + +public sealed class GameStarRating +{ + public decimal Score; + public int Total; +} + +public sealed class GameLanguageCode +{ + public string Name; + public string[] Codes; +} + +public sealed class GameUpsellInfo +{ + public string Type; + public string DisplayPrice; + public bool IsFree; + public decimal DiscountPercentageDifference; +} + +public sealed class GameSkuRelation +{ + public string Id; + public string Name; +} + +public sealed class FirmwareInfo +{ + public string Version; + public string DownloadUrl; + public string Locale; +} +#nullable restore \ No newline at end of file diff --git a/Clients/PsnClient/POCOs/Relationships.cs b/Clients/PsnClient/POCOs/Relationships.cs index 26f3ed0c..83e1a79b 100644 --- a/Clients/PsnClient/POCOs/Relationships.cs +++ b/Clients/PsnClient/POCOs/Relationships.cs @@ -1,26 +1,24 @@ -namespace PsnClient.POCOs +namespace PsnClient.POCOs; +#nullable disable +public class Relationships { - #nullable disable - public class Relationships - { - public RelationshipsChildren Children; - public RelationshipsLegacySkus LegacySkus; - } + public RelationshipsChildren Children; + public RelationshipsLegacySkus LegacySkus; +} - public class RelationshipsChildren - { - public RelationshipsChildrenItem[] Data; - } +public class RelationshipsChildren +{ + public RelationshipsChildrenItem[] Data; +} - public class RelationshipsChildrenItem - { - public string Id; - public string Type; - } +public class RelationshipsChildrenItem +{ + public string Id; + public string Type; +} - public class RelationshipsLegacySkus - { - public RelationshipsChildrenItem[] Data; - } - #nullable restore -} \ No newline at end of file +public class RelationshipsLegacySkus +{ + public RelationshipsChildrenItem[] Data; +} +#nullable restore \ No newline at end of file diff --git a/Clients/PsnClient/POCOs/StoreNavigation.cs b/Clients/PsnClient/POCOs/StoreNavigation.cs index 9ad28bf0..2b089bd4 100644 --- a/Clients/PsnClient/POCOs/StoreNavigation.cs +++ b/Clients/PsnClient/POCOs/StoreNavigation.cs @@ -1,50 +1,48 @@ -namespace PsnClient.POCOs +namespace PsnClient.POCOs; +#nullable disable +public class StoreNavigation { - #nullable disable - public class StoreNavigation - { - public StoreNavigationData Data; - //public StoreNavigationIncluded Included; - } - - public class StoreNavigationData - { - public string Id; - public string Type; - public StoreNavigationAttributes Attributes; - public Relationships Relationships; - } - - public class StoreNavigationAttributes - { - public string Name; - public StoreNavigationNavigation[] Navigation; - } - - public class StoreNavigationNavigation - { - public string Id; - public string Name; - public string TargetContainerId; - public string RouteName; - public StoreNavigationSubmenu[] Submenu; - } - - public class StoreNavigationSubmenu - { - public string Name; - public string TargetContainerId; - public int? TemplateDefId; - public StoreNavigationSubmenuItem[] Items; - } - - public class StoreNavigationSubmenuItem - { - public string Name; - public string TargetContainerId; - public string TargetContainerType; - public int? TemplateDefId; - public bool IsSeparator; - } - #nullable restore + public StoreNavigationData Data; + //public StoreNavigationIncluded Included; } + +public class StoreNavigationData +{ + public string Id; + public string Type; + public StoreNavigationAttributes Attributes; + public Relationships Relationships; +} + +public class StoreNavigationAttributes +{ + public string Name; + public StoreNavigationNavigation[] Navigation; +} + +public class StoreNavigationNavigation +{ + public string Id; + public string Name; + public string TargetContainerId; + public string RouteName; + public StoreNavigationSubmenu[] Submenu; +} + +public class StoreNavigationSubmenu +{ + public string Name; + public string TargetContainerId; + public int? TemplateDefId; + public StoreNavigationSubmenuItem[] Items; +} + +public class StoreNavigationSubmenuItem +{ + public string Name; + public string TargetContainerId; + public string TargetContainerType; + public int? TemplateDefId; + public bool IsSeparator; +} +#nullable restore \ No newline at end of file diff --git a/Clients/PsnClient/POCOs/Stores.cs b/Clients/PsnClient/POCOs/Stores.cs index fbf8b1d9..a954f6e1 100644 --- a/Clients/PsnClient/POCOs/Stores.cs +++ b/Clients/PsnClient/POCOs/Stores.cs @@ -1,32 +1,30 @@ using System.Text.Json.Serialization; -namespace PsnClient.POCOs +namespace PsnClient.POCOs; +#nullable disable +// https://store.playstation.com/kamaji/api/valkyrie_storefront/00_09_000/user/stores +// requires session +public class Stores { - #nullable disable - // https://store.playstation.com/kamaji/api/valkyrie_storefront/00_09_000/user/stores - // requires session - public class Stores - { - public StoresHeader Header; - public StoresData Data; - } - - public class StoresHeader - { - public string Details; - [JsonPropertyName("errorUUID")] - public string ErrorUuid; - - public string MessageKey; // "success" - public string StatusCode; // "0x0000" - } - - public class StoresData - { - public string BaseUrl; - public string RootUrl; - public string SearchUrl; - public string TumblerUrl; - } - #nullable restore + public StoresHeader Header; + public StoresData Data; } + +public class StoresHeader +{ + public string Details; + [JsonPropertyName("errorUUID")] + public string ErrorUuid; + + public string MessageKey; // "success" + public string StatusCode; // "0x0000" +} + +public class StoresData +{ + public string BaseUrl; + public string RootUrl; + public string SearchUrl; + public string TumblerUrl; +} +#nullable restore \ No newline at end of file diff --git a/Clients/PsnClient/POCOs/TitleMeta.cs b/Clients/PsnClient/POCOs/TitleMeta.cs index 0708b0a6..a3e4c551 100644 --- a/Clients/PsnClient/POCOs/TitleMeta.cs +++ b/Clients/PsnClient/POCOs/TitleMeta.cs @@ -1,39 +1,37 @@ using System.Xml.Serialization; -namespace PsnClient.POCOs +namespace PsnClient.POCOs; +#nullable disable + +[XmlRoot("title-info")] +public class TitleMeta { - #nullable disable - - [XmlRoot("title-info")] - public class TitleMeta - { - [XmlAttribute("rev")] - public int Rev { get; set; } - [XmlElement("id")] - public string Id { get; set; } - [XmlElement("console")] - public string Console { get; set; } - [XmlElement("media-type")] - public string MediaType { get; set; } - [XmlElement("name")] - public string Name { get; set; } - [XmlElement("parental-level")] - public int ParentalLevel { get; set; } - [XmlElement("icon")] - public TitleIcon Icon { get; set; } - [XmlElement("resolution")] - public string Resolution { get; set; } - [XmlElement("sound-format")] - public string SoundFormat { get; set; } - } - - public class TitleIcon - { - [XmlAttribute("type")] - public string Type { get; set; } - [XmlText] - public string Url { get; set; } - } - - #nullable restore + [XmlAttribute("rev")] + public int Rev { get; set; } + [XmlElement("id")] + public string Id { get; set; } + [XmlElement("console")] + public string Console { get; set; } + [XmlElement("media-type")] + public string MediaType { get; set; } + [XmlElement("name")] + public string Name { get; set; } + [XmlElement("parental-level")] + public int ParentalLevel { get; set; } + [XmlElement("icon")] + public TitleIcon Icon { get; set; } + [XmlElement("resolution")] + public string Resolution { get; set; } + [XmlElement("sound-format")] + public string SoundFormat { get; set; } } + +public class TitleIcon +{ + [XmlAttribute("type")] + public string Type { get; set; } + [XmlText] + public string Url { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/Clients/PsnClient/POCOs/TitlePatch.cs b/Clients/PsnClient/POCOs/TitlePatch.cs index 7847c6f5..e90442e2 100644 --- a/Clients/PsnClient/POCOs/TitlePatch.cs +++ b/Clients/PsnClient/POCOs/TitlePatch.cs @@ -1,53 +1,51 @@ using System; using System.Xml.Serialization; -namespace PsnClient.POCOs +namespace PsnClient.POCOs; +#nullable disable + +[XmlRoot("titlepatch")] +public class TitlePatch { - #nullable disable - - [XmlRoot("titlepatch")] - public class TitlePatch - { - [XmlAttribute("titleid")] - public string TitleId { get; set; } - [XmlAttribute("status")] - public string Status { get; set; } - [XmlElement("tag")] - public TitlePatchTag Tag { get; set; } - [XmlIgnore] - public DateTime? OfflineCacheTimestamp { get; set; } - } - - public class TitlePatchTag - { - [XmlAttribute("name")] - public string Name { get; set; } - //no root element - [XmlElement("package")] - public TitlePatchPackage[] Packages { get; set; } - } - - public class TitlePatchPackage - { - [XmlAttribute("version")] - public string Version { get; set; } - [XmlAttribute("size")] - public long Size { get; set; } - [XmlAttribute("sha1sum")] - public string Sha1Sum { get; set; } - [XmlAttribute("url")] - public string Url { get; set; } - [XmlAttribute("ps3_system_ver")] - public string Ps3SystemVer { get; set; } - [XmlElement("paramsfo")] - public TitlePatchParamSfo ParamSfo { get; set; } - } - - public class TitlePatchParamSfo - { - [XmlElement("TITLE")] - public string Title { get; set; } - } - - #nullable restore + [XmlAttribute("titleid")] + public string TitleId { get; set; } + [XmlAttribute("status")] + public string Status { get; set; } + [XmlElement("tag")] + public TitlePatchTag Tag { get; set; } + [XmlIgnore] + public DateTime? OfflineCacheTimestamp { get; set; } } + +public class TitlePatchTag +{ + [XmlAttribute("name")] + public string Name { get; set; } + //no root element + [XmlElement("package")] + public TitlePatchPackage[] Packages { get; set; } +} + +public class TitlePatchPackage +{ + [XmlAttribute("version")] + public string Version { get; set; } + [XmlAttribute("size")] + public long Size { get; set; } + [XmlAttribute("sha1sum")] + public string Sha1Sum { get; set; } + [XmlAttribute("url")] + public string Url { get; set; } + [XmlAttribute("ps3_system_ver")] + public string Ps3SystemVer { get; set; } + [XmlElement("paramsfo")] + public TitlePatchParamSfo ParamSfo { get; set; } +} + +public class TitlePatchParamSfo +{ + [XmlElement("TITLE")] + public string Title { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/Clients/PsnClient/PsnClient.cs b/Clients/PsnClient/PsnClient.cs index 21dcae72..bb00a8ad 100644 --- a/Clients/PsnClient/PsnClient.cs +++ b/Clients/PsnClient/PsnClient.cs @@ -18,124 +18,64 @@ using Microsoft.Extensions.Caching.Memory; using PsnClient.POCOs; using PsnClient.Utils; -namespace PsnClient +namespace PsnClient; + +public class Client { - public class Client + private readonly HttpClient client; + private readonly JsonSerializerOptions dashedJson; + private readonly JsonSerializerOptions snakeJson; + private readonly MediaTypeFormatterCollection xmlFormatters; + private static readonly MemoryCache ResponseCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromHours(1) }); + private static readonly TimeSpan ResponseCacheDuration = TimeSpan.FromHours(1); + private static readonly Regex ContainerIdLink = new(@"(?STORE-(\w|\d)+-(\w|\d)+)"); + private static readonly string[] KnownStoreLocales = { - private readonly HttpClient client; - private readonly JsonSerializerOptions dashedJson; - private readonly JsonSerializerOptions snakeJson; - private readonly MediaTypeFormatterCollection xmlFormatters; - private static readonly MemoryCache ResponseCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromHours(1) }); - private static readonly TimeSpan ResponseCacheDuration = TimeSpan.FromHours(1); - private static readonly Regex ContainerIdLink = new(@"(?STORE-(\w|\d)+-(\w|\d)+)"); - private static readonly string[] KnownStoreLocales = + "en-US", "en-GB", "en-AE", "en-AU", "en-BG", "en-BH", "en-CA", "en-CY", "en-CZ", "en-DK", "en-FI", "en-GR", "en-HK", "en-HR", "en-HU", "en-ID", "en-IE", "en-IL", "en-IN", "en-IS", + "en-KW", "en-LB", "en-MT", "en-MY", "en-NO", "en-NZ", "en-OM", "en-PL", "en-QA", "en-RO", "en-SA", "en-SE", "en-SG", "en-SI", "en-SK", "en-TH", "en-TR", "en-TW", "en-ZA", "ja-JP", + "ar-AE", "ar-BH", "ar-KW", "ar-LB", "ar-OM", "ar-QA", "ar-SA", "da-DK", "de-AT", "de-CH", "de-DE", "de-LU", "es-AR", "es-BO", "es-CL", "es-CO", "es-CR", "es-EC", "es-ES", "es-GT", + "es-HN", "es-MX", "es-NI", "es-PA", "es-PE", "es-PY", "es-SV", "es-UY", "fi-FI", "fr-BE", "fr-CA", "fr-CH", "fr-FR", "fr-LU", "it-CH", "it-IT", "ko-KR", "nl-BE", "nl-NL", "no-NO", + "pl-PL", "pt-BR", "pt-PT", "ru-RU", "ru-UA", "sv-SE", "tr-TR", "zh-Hans-CN", "zh-Hans-HK", "zh-Hant-HK", "zh-Hant-TW", + }; + // Dest=87;ImageVersion=0001091d;SystemSoftwareVersion=4.8500;CDN=http://duk01.ps3.update.playstation.net/update/ps3/image/uk/2019_0828_c975768e5d70e105a72656f498cc9be9/PS3UPDAT.PUP;CDN_Timeout=30; + private static readonly Regex FwVersionInfo = new(@"Dest=(?\d+);ImageVersion=(?[0-9a-f]+);SystemSoftwareVersion=(?\d+\.\d+);CDN=(?http[^;]+);CDN_Timeout=(?\d+)", + RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Singleline | RegexOptions.IgnoreCase); + + // directly from vsh.self + private static readonly string[] KnownFwLocales = { "jp", "us", "eu", "kr", "uk", "mx", "au", "sa", "tw", "ru", "cn", "br", }; + + public Client() + { + client = HttpClientFactory.Create(new CustomTlsCertificatesHandler(), new CompressionMessageHandler()); + dashedJson = new JsonSerializerOptions { - "en-US", "en-GB", "en-AE", "en-AU", "en-BG", "en-BH", "en-CA", "en-CY", "en-CZ", "en-DK", "en-FI", "en-GR", "en-HK", "en-HR", "en-HU", "en-ID", "en-IE", "en-IL", "en-IN", "en-IS", - "en-KW", "en-LB", "en-MT", "en-MY", "en-NO", "en-NZ", "en-OM", "en-PL", "en-QA", "en-RO", "en-SA", "en-SE", "en-SG", "en-SI", "en-SK", "en-TH", "en-TR", "en-TW", "en-ZA", "ja-JP", - "ar-AE", "ar-BH", "ar-KW", "ar-LB", "ar-OM", "ar-QA", "ar-SA", "da-DK", "de-AT", "de-CH", "de-DE", "de-LU", "es-AR", "es-BO", "es-CL", "es-CO", "es-CR", "es-EC", "es-ES", "es-GT", - "es-HN", "es-MX", "es-NI", "es-PA", "es-PE", "es-PY", "es-SV", "es-UY", "fi-FI", "fr-BE", "fr-CA", "fr-CH", "fr-FR", "fr-LU", "it-CH", "it-IT", "ko-KR", "nl-BE", "nl-NL", "no-NO", - "pl-PL", "pt-BR", "pt-PT", "ru-RU", "ru-UA", "sv-SE", "tr-TR", "zh-Hans-CN", "zh-Hans-HK", "zh-Hant-HK", "zh-Hant-TW", + PropertyNamingPolicy = SpecialJsonNamingPolicy.Dashed, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, }; - // Dest=87;ImageVersion=0001091d;SystemSoftwareVersion=4.8500;CDN=http://duk01.ps3.update.playstation.net/update/ps3/image/uk/2019_0828_c975768e5d70e105a72656f498cc9be9/PS3UPDAT.PUP;CDN_Timeout=30; - private static readonly Regex FwVersionInfo = new(@"Dest=(?\d+);ImageVersion=(?[0-9a-f]+);SystemSoftwareVersion=(?\d+\.\d+);CDN=(?http[^;]+);CDN_Timeout=(?\d+)", - RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Singleline | RegexOptions.IgnoreCase); - - // directly from vsh.self - private static readonly string[] KnownFwLocales = { "jp", "us", "eu", "kr", "uk", "mx", "au", "sa", "tw", "ru", "cn", "br", }; - - public Client() + snakeJson = new JsonSerializerOptions { - client = HttpClientFactory.Create(new CustomTlsCertificatesHandler(), new CompressionMessageHandler()); - dashedJson = new JsonSerializerOptions - { - PropertyNamingPolicy = SpecialJsonNamingPolicy.Dashed, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - IncludeFields = true, - }; - snakeJson = new JsonSerializerOptions - { - PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - IncludeFields = true, - }; - xmlFormatters = new MediaTypeFormatterCollection(new[] {new XmlMediaTypeFormatter {UseXmlSerializer = true}}); - } + PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + }; + xmlFormatters = new MediaTypeFormatterCollection(new[] {new XmlMediaTypeFormatter {UseXmlSerializer = true}}); + } - public static string[] GetLocales() => KnownStoreLocales; // Sony removed the ability to get the full store list, now relying on geolocation service instead + public static string[] GetLocales() => KnownStoreLocales; // Sony removed the ability to get the full store list, now relying on geolocation service instead - public async Task GetStoresAsync(string locale, CancellationToken cancellationToken) + public async Task GetStoresAsync(string locale, CancellationToken cancellationToken) + { + try { + var cookieHeaderValue = await GetSessionCookies(locale, cancellationToken).ConfigureAwait(false); + using var getMessage = new HttpRequestMessage(HttpMethod.Get, "https://store.playstation.com/kamaji/api/valkyrie_storefront/00_09_000/user/stores"); + getMessage.Headers.Add("Cookie", cookieHeaderValue); + using var response = await client.SendAsync(getMessage, cancellationToken).ConfigureAwait(false); try { - var cookieHeaderValue = await GetSessionCookies(locale, cancellationToken).ConfigureAwait(false); - using var getMessage = new HttpRequestMessage(HttpMethod.Get, "https://store.playstation.com/kamaji/api/valkyrie_storefront/00_09_000/user/stores"); - getMessage.Headers.Add("Cookie", cookieHeaderValue); - using var response = await client.SendAsync(getMessage, cancellationToken).ConfigureAwait(false); - try - { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync(snakeJson, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - return null; - } - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - return null; - } - } - - public async Task?> GetMainPageNavigationContainerIdsAsync(string locale, CancellationToken cancellationToken) - { - HttpResponseMessage? response = null; - try - { - var baseUrl = $"https://store.playstation.com/{locale}/"; - var sessionCookies = await GetSessionCookies(locale, cancellationToken).ConfigureAwait(false); - using (var message = new HttpRequestMessage(HttpMethod.Get, baseUrl)) - { - message.Headers.Add("Cookie", sessionCookies); - response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - var tries = 0; - while (response.StatusCode == HttpStatusCode.Redirect && tries < 10 && !cancellationToken.IsCancellationRequested) - { - using (var newLocationMessage = new HttpRequestMessage(HttpMethod.Get, response.Headers.Location)) - { - newLocationMessage.Headers.Add("Cookie", sessionCookies); - var redirectResponse = await client.SendAsync(newLocationMessage, cancellationToken).ConfigureAwait(false); - response.Dispose(); - response = redirectResponse; - } - tries++; - } - if (response.StatusCode == HttpStatusCode.Redirect) - return new List(0); - } - - using (response) - try - { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var matches = ContainerIdLink.Matches(html); - var result = new List(); - foreach (Match m in matches) - if (m.Groups["id"].Value is string id && !string.IsNullOrEmpty(id)) - result.Add(id); - return result; - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - return null; - } + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + return await response.Content.ReadFromJsonAsync(snakeJson, cancellationToken).ConfigureAwait(false); } catch (Exception e) { @@ -143,306 +83,365 @@ namespace PsnClient return null; } } - - public async Task GetStoreNavigationAsync(string locale, string containerId, CancellationToken cancellationToken) + catch (Exception e) { - try + ApiConfig.Log.Error(e); + return null; + } + } + + public async Task?> GetMainPageNavigationContainerIdsAsync(string locale, CancellationToken cancellationToken) + { + HttpResponseMessage? response = null; + try + { + var baseUrl = $"https://store.playstation.com/{locale}/"; + var sessionCookies = await GetSessionCookies(locale, cancellationToken).ConfigureAwait(false); + using (var message = new HttpRequestMessage(HttpMethod.Get, baseUrl)) { - var (language, country) = locale.AsLocaleData(); - var baseUrl = $"https://store.playstation.com/valkyrie-api/{language}/{country}/999/storefront/{containerId}"; - using var message = new HttpRequestMessage(HttpMethod.Get, baseUrl); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + message.Headers.Add("Cookie", sessionCookies); + response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + var tries = 0; + while (response.StatusCode == HttpStatusCode.Redirect && tries < 10 && !cancellationToken.IsCancellationRequested) + { + using (var newLocationMessage = new HttpRequestMessage(HttpMethod.Get, response.Headers.Location)) + { + newLocationMessage.Headers.Add("Cookie", sessionCookies); + var redirectResponse = await client.SendAsync(newLocationMessage, cancellationToken).ConfigureAwait(false); + response.Dispose(); + response = redirectResponse; + } + tries++; + } + if (response.StatusCode == HttpStatusCode.Redirect) + return new List(0); + } + + using (response) try { - if (response.StatusCode == HttpStatusCode.NotFound) - return null; - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync(dashedJson, cancellationToken).ConfigureAwait(false); + var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var matches = ContainerIdLink.Matches(html); + var result = new List(); + foreach (Match m in matches) + if (m.Groups["id"].Value is string id && !string.IsNullOrEmpty(id)) + result.Add(id); + return result; } catch (Exception e) { ConsoleLogger.PrintError(e, response); return null; } - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - return null; - } } - - public async Task GetGameContainerAsync(string locale, string containerId, int start, int take, Dictionary filters, CancellationToken cancellationToken) + catch (Exception e) { - try - { - var (language, country) = locale.AsLocaleData(); - var url = new Uri($"https://store.playstation.com/valkyrie-api/{language}/{country}/999/container/{containerId}"); - filters["start"] = start.ToString(); - filters["size"] = take.ToString(); - filters["bucket"] = "games"; - url = url.SetQueryParameters(filters!); - using var message = new HttpRequestMessage(HttpMethod.Get, url); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - try - { - if (response.StatusCode == HttpStatusCode.NotFound) - return null; - - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync(dashedJson, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - return null; - } - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - return null; - } + ConsoleLogger.PrintError(e, response); + return null; } + } - public async Task ResolveContentAsync(string locale, string contentId, int depth, CancellationToken cancellationToken) + public async Task GetStoreNavigationAsync(string locale, string containerId, CancellationToken cancellationToken) + { + try { - try - { - var (language, country) = locale.AsLocaleData(); - using var message = new HttpRequestMessage(HttpMethod.Get, $"https://store.playstation.com/valkyrie-api/{language}/{country}/999/resolve/{contentId}?depth={depth}"); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - try - { - if (response.StatusCode == HttpStatusCode.NotFound) - return null; - - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync(dashedJson, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - return null; - } - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - return null; - } - } - - public async Task<(TitlePatch? patch, string? responseXml)> GetTitleUpdatesAsync(string? productId, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(productId)) - return default; - - if (ResponseCache.TryGetValue(productId, out TitlePatch patchInfo)) - return (patchInfo, default); - - using var message = new HttpRequestMessage(HttpMethod.Get, $"https://a0.ww.np.dl.playstation.net/tpl/np/{productId}/{productId}-ver.xml"); + var (language, country) = locale.AsLocaleData(); + var baseUrl = $"https://store.playstation.com/valkyrie-api/{language}/{country}/999/storefront/{containerId}"; + using var message = new HttpRequestMessage(HttpMethod.Get, baseUrl); using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); try { if (response.StatusCode == HttpStatusCode.NotFound) - return default; + return null; await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - var xml = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - patchInfo = await response.Content.ReadAsAsync(xmlFormatters, cancellationToken).ConfigureAwait(false); - ResponseCache.Set(productId, patchInfo, ResponseCacheDuration); - return (patchInfo, xml); + return await response.Content.ReadFromJsonAsync(dashedJson, cancellationToken).ConfigureAwait(false); } catch (Exception e) { ConsoleLogger.PrintError(e, response); - throw; - } - } - - public async Task GetTitleMetaAsync(string productId, CancellationToken cancellationToken) - { - var id = productId + "_00"; - if (ResponseCache.TryGetValue(id, out TitleMeta? meta)) - return meta; - - var hash = TmdbHasher.GetTitleHash(id); - try - { - using var message = new HttpRequestMessage(HttpMethod.Get, $"https://tmdb.np.dl.playstation.net/tmdb/{id}_{hash}/{id}.xml"); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - try - { - if (response.StatusCode == HttpStatusCode.NotFound) - return null; - - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - meta = await response.Content.ReadAsAsync(xmlFormatters, cancellationToken).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - ResponseCache.Set(id, meta, ResponseCacheDuration); - return meta; - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - return null; - } - } - catch (Exception e) - { - ApiConfig.Log.Error(e); return null; } } - - public async Task SearchAsync(string locale, string search, CancellationToken cancellationToken) + catch (Exception e) { - try - { - var (language, country) = locale.AsLocaleData(); - var searchId = Uri.EscapeDataString(search); // was EscapeUriString for some reason I don't remember exactly - var queryId = Uri.EscapeDataString(searchId); - var uri = new Uri($"https://store.playstation.com/valkyrie-api/{language}/{country}/999/faceted-search/{searchId}?query={queryId}&game_content_type=games&size=30&bucket=games&platform=ps3&start=0"); - using var message = new HttpRequestMessage(HttpMethod.Get, uri); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - try - { - if (response.StatusCode == HttpStatusCode.NotFound) - return null; - - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync(dashedJson, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - return null; - } - } - catch (Exception e) - { - ApiConfig.Log.Error(e); - return null; - } - } - - public async Task> GetHighestFwVersionAsync(CancellationToken cancellationToken) - { - var tasks = new List>(KnownFwLocales.Length); - foreach (var fwLocale in KnownFwLocales) - tasks.Add(GetFwVersionAsync(fwLocale, cancellationToken)); - var allVersions = new List(KnownFwLocales.Length); - foreach (var t in tasks) - try - { - if (await t.ConfigureAwait(false) is FirmwareInfo ver) - allVersions.Add(ver); - } - catch { } - - allVersions = allVersions.OrderByDescending(fwi => fwi.Version).ToList(); - if (allVersions.Count == 0) - return new List(0); - - var maxFw = allVersions.First(); - var result = allVersions.Where(fwi => fwi.Version == maxFw.Version).ToList(); - return result; - } - - private async Task GetSessionCookies(string locale, CancellationToken cancellationToken) - { - var (language, country) = locale.AsLocaleData(); - var uri = new Uri("https://store.playstation.com/kamaji/api/valkyrie_storefront/00_09_000/user/session"); - var tries = 0; - do - { - try - { - HttpResponseMessage response; - using (var deleteMessage = new HttpRequestMessage(HttpMethod.Delete, uri)) - using (response = await client.SendAsync(deleteMessage, cancellationToken)) - if (response.StatusCode != HttpStatusCode.OK) - ConsoleLogger.PrintError(new InvalidOperationException("Couldn't delete current session"), response, false); - - var authMessage = new HttpRequestMessage(HttpMethod.Post, uri) - { - Content = new FormUrlEncodedContent(new Dictionary - { - ["country_code"] = country, - ["language_code"] = language, - }!) - }; - using (authMessage) - using (response = await client.SendAsync(authMessage, cancellationToken).ConfigureAwait(false)) - try - { - var cookieContainer = new CookieContainer(); - foreach (var cookie in response.Headers.GetValues("set-cookie")) - cookieContainer.SetCookies(uri, cookie); - return cookieContainer.GetCookieHeader(uri); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response, tries > 1); - tries++; - } - } - catch (Exception e) - { - if (tries < 3) - ApiConfig.Log.Warn(e); - else - ApiConfig.Log.Error(e); - tries++; - } - } while (tries < 3); - throw new InvalidOperationException("Couldn't obtain web session"); - } - - private async Task GetFwVersionAsync(string fwLocale, CancellationToken cancellationToken) - { - var uri = new Uri($"http://f{fwLocale}01.ps3.update.playstation.net/update/ps3/list/{fwLocale}/ps3-updatelist.txt"); - try - { - using var message = new HttpRequestMessage(HttpMethod.Get, uri); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - try - { - if (response.StatusCode != HttpStatusCode.OK) - return null; - - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - var data = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(data)) - return null; - - if (FwVersionInfo.Match(data) is not Match m || !m.Success) - return null; - - var ver = m.Groups["version"].Value; - if (ver.Length > 4) - { - if (ver.EndsWith("00")) - ver = ver[..4]; //4.85 - else - ver = ver[..4] + "." + ver[4..].TrimEnd('0'); //4.851 -> 4.85.1 - } - return new FirmwareInfo { Version = ver, DownloadUrl = m.Groups["url"].Value, Locale = fwLocale}; - - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - return null; - } - } - catch (Exception e) - { - ApiConfig.Log.Error(e, "Failed to GET " + uri); - return null; - } + ApiConfig.Log.Error(e); + return null; } } -} + + public async Task GetGameContainerAsync(string locale, string containerId, int start, int take, Dictionary filters, CancellationToken cancellationToken) + { + try + { + var (language, country) = locale.AsLocaleData(); + var url = new Uri($"https://store.playstation.com/valkyrie-api/{language}/{country}/999/container/{containerId}"); + filters["start"] = start.ToString(); + filters["size"] = take.ToString(); + filters["bucket"] = "games"; + url = url.SetQueryParameters(filters!); + using var message = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + try + { + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + return await response.Content.ReadFromJsonAsync(dashedJson, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response); + return null; + } + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + return null; + } + } + + public async Task ResolveContentAsync(string locale, string contentId, int depth, CancellationToken cancellationToken) + { + try + { + var (language, country) = locale.AsLocaleData(); + using var message = new HttpRequestMessage(HttpMethod.Get, $"https://store.playstation.com/valkyrie-api/{language}/{country}/999/resolve/{contentId}?depth={depth}"); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + try + { + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + return await response.Content.ReadFromJsonAsync(dashedJson, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response); + return null; + } + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + return null; + } + } + + public async Task<(TitlePatch? patch, string? responseXml)> GetTitleUpdatesAsync(string? productId, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(productId)) + return default; + + if (ResponseCache.TryGetValue(productId, out TitlePatch patchInfo)) + return (patchInfo, default); + + using var message = new HttpRequestMessage(HttpMethod.Get, $"https://a0.ww.np.dl.playstation.net/tpl/np/{productId}/{productId}-ver.xml"); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + try + { + if (response.StatusCode == HttpStatusCode.NotFound) + return default; + + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + var xml = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + patchInfo = await response.Content.ReadAsAsync(xmlFormatters, cancellationToken).ConfigureAwait(false); + ResponseCache.Set(productId, patchInfo, ResponseCacheDuration); + return (patchInfo, xml); + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response); + throw; + } + } + + public async Task GetTitleMetaAsync(string productId, CancellationToken cancellationToken) + { + var id = productId + "_00"; + if (ResponseCache.TryGetValue(id, out TitleMeta? meta)) + return meta; + + var hash = TmdbHasher.GetTitleHash(id); + try + { + using var message = new HttpRequestMessage(HttpMethod.Get, $"https://tmdb.np.dl.playstation.net/tmdb/{id}_{hash}/{id}.xml"); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + try + { + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + meta = await response.Content.ReadAsAsync(xmlFormatters, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + ResponseCache.Set(id, meta, ResponseCacheDuration); + return meta; + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response); + return null; + } + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + return null; + } + } + + public async Task SearchAsync(string locale, string search, CancellationToken cancellationToken) + { + try + { + var (language, country) = locale.AsLocaleData(); + var searchId = Uri.EscapeDataString(search); // was EscapeUriString for some reason I don't remember exactly + var queryId = Uri.EscapeDataString(searchId); + var uri = new Uri($"https://store.playstation.com/valkyrie-api/{language}/{country}/999/faceted-search/{searchId}?query={queryId}&game_content_type=games&size=30&bucket=games&platform=ps3&start=0"); + using var message = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + try + { + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + return await response.Content.ReadFromJsonAsync(dashedJson, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response); + return null; + } + } + catch (Exception e) + { + ApiConfig.Log.Error(e); + return null; + } + } + + public async Task> GetHighestFwVersionAsync(CancellationToken cancellationToken) + { + var tasks = new List>(KnownFwLocales.Length); + foreach (var fwLocale in KnownFwLocales) + tasks.Add(GetFwVersionAsync(fwLocale, cancellationToken)); + var allVersions = new List(KnownFwLocales.Length); + foreach (var t in tasks) + try + { + if (await t.ConfigureAwait(false) is FirmwareInfo ver) + allVersions.Add(ver); + } + catch { } + + allVersions = allVersions.OrderByDescending(fwi => fwi.Version).ToList(); + if (allVersions.Count == 0) + return new List(0); + + var maxFw = allVersions.First(); + var result = allVersions.Where(fwi => fwi.Version == maxFw.Version).ToList(); + return result; + } + + private async Task GetSessionCookies(string locale, CancellationToken cancellationToken) + { + var (language, country) = locale.AsLocaleData(); + var uri = new Uri("https://store.playstation.com/kamaji/api/valkyrie_storefront/00_09_000/user/session"); + var tries = 0; + do + { + try + { + HttpResponseMessage response; + using (var deleteMessage = new HttpRequestMessage(HttpMethod.Delete, uri)) + using (response = await client.SendAsync(deleteMessage, cancellationToken)) + if (response.StatusCode != HttpStatusCode.OK) + ConsoleLogger.PrintError(new InvalidOperationException("Couldn't delete current session"), response, false); + + var authMessage = new HttpRequestMessage(HttpMethod.Post, uri) + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["country_code"] = country, + ["language_code"] = language, + }!) + }; + using (authMessage) + using (response = await client.SendAsync(authMessage, cancellationToken).ConfigureAwait(false)) + try + { + var cookieContainer = new CookieContainer(); + foreach (var cookie in response.Headers.GetValues("set-cookie")) + cookieContainer.SetCookies(uri, cookie); + return cookieContainer.GetCookieHeader(uri); + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response, tries > 1); + tries++; + } + } + catch (Exception e) + { + if (tries < 3) + ApiConfig.Log.Warn(e); + else + ApiConfig.Log.Error(e); + tries++; + } + } while (tries < 3); + throw new InvalidOperationException("Couldn't obtain web session"); + } + + private async Task GetFwVersionAsync(string fwLocale, CancellationToken cancellationToken) + { + var uri = new Uri($"http://f{fwLocale}01.ps3.update.playstation.net/update/ps3/list/{fwLocale}/ps3-updatelist.txt"); + try + { + using var message = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); + try + { + if (response.StatusCode != HttpStatusCode.OK) + return null; + + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + var data = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(data)) + return null; + + if (FwVersionInfo.Match(data) is not Match m || !m.Success) + return null; + + var ver = m.Groups["version"].Value; + if (ver.Length > 4) + { + if (ver.EndsWith("00")) + ver = ver[..4]; //4.85 + else + ver = ver[..4] + "." + ver[4..].TrimEnd('0'); //4.851 -> 4.85.1 + } + return new FirmwareInfo { Version = ver, DownloadUrl = m.Groups["url"].Value, Locale = fwLocale}; + + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response); + return null; + } + } + catch (Exception e) + { + ApiConfig.Log.Error(e, "Failed to GET " + uri); + return null; + } + } +} \ No newline at end of file diff --git a/Clients/PsnClient/Utils/LocaleUtils.cs b/Clients/PsnClient/Utils/LocaleUtils.cs index 206f7b89..6d544db0 100644 --- a/Clients/PsnClient/Utils/LocaleUtils.cs +++ b/Clients/PsnClient/Utils/LocaleUtils.cs @@ -1,18 +1,17 @@ -namespace PsnClient.Utils +namespace PsnClient.Utils; + +public static class LocaleUtils { - public static class LocaleUtils + public static (string language, string country) AsLocaleData(this string locale) { - public static (string language, string country) AsLocaleData(this string locale) - { - /* - "zh-Hans-CN" -> zh-CN - "zh-Hans-HK" -> zh-HK - "zh-Hant-HK" -> ch-HK - "zh-Hant-TW" -> ch-TW - */ - locale = locale.Replace("zh-Hans", "zh").Replace("zh-Hant", "ch"); - var localeParts = locale.Split('-'); - return (localeParts[0], localeParts[1]); - } + /* + "zh-Hans-CN" -> zh-CN + "zh-Hans-HK" -> zh-HK + "zh-Hant-HK" -> ch-HK + "zh-Hant-TW" -> ch-TW + */ + locale = locale.Replace("zh-Hans", "zh").Replace("zh-Hant", "ch"); + var localeParts = locale.Split('-'); + return (localeParts[0], localeParts[1]); } -} +} \ No newline at end of file diff --git a/Clients/PsnClient/Utils/TmdbHasher.cs b/Clients/PsnClient/Utils/TmdbHasher.cs index 009944ba..8fa4167f 100644 --- a/Clients/PsnClient/Utils/TmdbHasher.cs +++ b/Clients/PsnClient/Utils/TmdbHasher.cs @@ -3,41 +3,40 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; -namespace PsnClient.Utils +namespace PsnClient.Utils; + +public static class TmdbHasher { - public static class TmdbHasher + private static readonly byte[] HmacKey = "F5DE66D2680E255B2DF79E74F890EBF349262F618BCAE2A9ACCDEE5156CE8DF2CDF2D48C71173CDC2594465B87405D197CF1AED3B7E9671EEB56CA6753C2E6B0".FromHexString(); + + public static string GetTitleHash(string productId) { - private static readonly byte[] HmacKey = "F5DE66D2680E255B2DF79E74F890EBF349262F618BCAE2A9ACCDEE5156CE8DF2CDF2D48C71173CDC2594465B87405D197CF1AED3B7E9671EEB56CA6753C2E6B0".FromHexString(); - - public static string GetTitleHash(string productId) - { - using var hmacSha1 = new HMACSHA1(HmacKey); - return hmacSha1.ComputeHash(Encoding.ASCII.GetBytes(productId)).ToHexString(); - } - - public static byte[] FromHexString(this string hexString) - { - if (hexString.Length == 0) - return Array.Empty(); - - if (hexString.Length % 2 != 0) - throw new ArgumentException("Invalid hex string format: odd number of octets", nameof(hexString)); - - var result = new byte[hexString.Length/2]; - for (int i = 0, j = 0; i < hexString.Length; i += 2, j++) - result[j] = byte.Parse(hexString.Substring(i, 2), NumberStyles.HexNumber); - return result; - } - - public static string ToHexString(this byte[] array) - { - if (array.Length == 0) - return ""; - - var result = new StringBuilder(array.Length*2); - foreach (var b in array) - result.Append(b.ToString("X2")); - return result.ToString(); - } + using var hmacSha1 = new HMACSHA1(HmacKey); + return hmacSha1.ComputeHash(Encoding.ASCII.GetBytes(productId)).ToHexString(); } -} + + public static byte[] FromHexString(this string hexString) + { + if (hexString.Length == 0) + return Array.Empty(); + + if (hexString.Length % 2 != 0) + throw new ArgumentException("Invalid hex string format: odd number of octets", nameof(hexString)); + + var result = new byte[hexString.Length/2]; + for (int i = 0, j = 0; i < hexString.Length; i += 2, j++) + result[j] = byte.Parse(hexString.Substring(i, 2), NumberStyles.HexNumber); + return result; + } + + public static string ToHexString(this byte[] array) + { + if (array.Length == 0) + return ""; + + var result = new StringBuilder(array.Length*2); + foreach (var b in array) + result.Append(b.ToString("X2")); + return result.ToString(); + } +} \ No newline at end of file diff --git a/Clients/YandexDiskClient/Client.cs b/Clients/YandexDiskClient/Client.cs index 4285b6c9..a5277841 100644 --- a/Clients/YandexDiskClient/Client.cs +++ b/Clients/YandexDiskClient/Client.cs @@ -11,54 +11,53 @@ using System.Text.Json.Serialization; using CompatApiClient.Formatters; using YandexDiskClient.POCOs; -namespace YandexDiskClient +namespace YandexDiskClient; + +public sealed class Client { - public sealed class Client + private readonly HttpClient client; + private readonly JsonSerializerOptions jsonOptions; + + public Client() { - private readonly HttpClient client; - private readonly JsonSerializerOptions jsonOptions; - - public Client() + client = HttpClientFactory.Create(new CompressionMessageHandler()); + jsonOptions = new JsonSerializerOptions { - client = HttpClientFactory.Create(new CompressionMessageHandler()); - jsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - IncludeFields = true, - }; - } + PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + }; + } - public Task GetResourceInfoAsync(string shareKey, CancellationToken cancellationToken) - => GetResourceInfoAsync(new Uri($"https://yadi.sk/d/{shareKey}"), cancellationToken); + public Task GetResourceInfoAsync(string shareKey, CancellationToken cancellationToken) + => GetResourceInfoAsync(new Uri($"https://yadi.sk/d/{shareKey}"), cancellationToken); - public async Task GetResourceInfoAsync(Uri publicUri, CancellationToken cancellationToken) + public async Task GetResourceInfoAsync(Uri publicUri, CancellationToken cancellationToken) + { + try { + var uri = new Uri($"https://cloud-api.yandex.net/v1/disk/public/resources").SetQueryParameters( + ("public_key", publicUri.ToString()), + ("fields", "size,name,file") + ); + using var message = new HttpRequestMessage(HttpMethod.Get, uri); + message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); + using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); try { - var uri = new Uri($"https://cloud-api.yandex.net/v1/disk/public/resources").SetQueryParameters( - ("public_key", publicUri.ToString()), - ("fields", "size,name,file") - ); - using var message = new HttpRequestMessage(HttpMethod.Get, uri); - message.Headers.UserAgent.Add(ApiConfig.ProductInfoHeader); - using var response = await client.SendAsync(message, cancellationToken).ConfigureAwait(false); - try - { - await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - ConsoleLogger.PrintError(e, response); - } + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + return await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception e) { - ApiConfig.Log.Error(e); + ConsoleLogger.PrintError(e, response); } - return null; } - + catch (Exception e) + { + ApiConfig.Log.Error(e); + } + return null; } + } \ No newline at end of file diff --git a/Clients/YandexDiskClient/POCOs/ResourceInfo.cs b/Clients/YandexDiskClient/POCOs/ResourceInfo.cs index 9a52edc1..a70674ee 100644 --- a/Clients/YandexDiskClient/POCOs/ResourceInfo.cs +++ b/Clients/YandexDiskClient/POCOs/ResourceInfo.cs @@ -1,20 +1,18 @@ -namespace YandexDiskClient.POCOs +namespace YandexDiskClient.POCOs; +#nullable disable + +public sealed class ResourceInfo { - #nullable disable + public int? Size; + public string Name; //RPCS3.log.gz + public string PublicKey; + public string Type; //file + public string MimeType; //application/x-gzip + public string File; // + public string MediaType; //compressed + public string Md5; + public string Sha256; + public long? Revision; +} - public sealed class ResourceInfo - { - public int? Size; - public string Name; //RPCS3.log.gz - public string PublicKey; - public string Type; //file - public string MimeType; //application/x-gzip - public string File; // - public string MediaType; //compressed - public string Md5; - public string Sha256; - public long? Revision; - } - - #nullable restore -} \ No newline at end of file +#nullable restore \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/CheckBaseAttributeWithReactions.cs b/CompatBot/Commands/Attributes/CheckBaseAttributeWithReactions.cs index 020c91fe..5e53dd6a 100644 --- a/CompatBot/Commands/Attributes/CheckBaseAttributeWithReactions.cs +++ b/CompatBot/Commands/Attributes/CheckBaseAttributeWithReactions.cs @@ -4,36 +4,35 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands.Attributes +namespace CompatBot.Commands.Attributes; + +internal abstract class CheckBaseAttributeWithReactions: CheckBaseAttribute { - internal abstract class CheckBaseAttributeWithReactions: CheckBaseAttribute + protected abstract Task IsAllowed(CommandContext ctx, bool help); + + public DiscordEmoji? ReactOnSuccess { get; } + public DiscordEmoji? ReactOnFailure { get; } + + public CheckBaseAttributeWithReactions(DiscordEmoji? reactOnSuccess = null, DiscordEmoji? reactOnFailure = null) { - protected abstract Task IsAllowed(CommandContext ctx, bool help); + ReactOnSuccess = reactOnSuccess; + ReactOnFailure = reactOnFailure; + } - public DiscordEmoji? ReactOnSuccess { get; } - public DiscordEmoji? ReactOnFailure { get; } - - public CheckBaseAttributeWithReactions(DiscordEmoji? reactOnSuccess = null, DiscordEmoji? reactOnFailure = null) + public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) + { + var result = await IsAllowed(ctx, help); + Config.Log.Debug($"Check for {GetType().Name} and user {ctx.User.Username}#{ctx.User.Discriminator} ({ctx.User.Id}) resulted in {result}"); + if (result) { - ReactOnSuccess = reactOnSuccess; - ReactOnFailure = reactOnFailure; + if (ReactOnSuccess != null && !help) + await ctx.ReactWithAsync(ReactOnSuccess).ConfigureAwait(false); } - - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) + else { - var result = await IsAllowed(ctx, help); - Config.Log.Debug($"Check for {GetType().Name} and user {ctx.User.Username}#{ctx.User.Discriminator} ({ctx.User.Id}) resulted in {result}"); - if (result) - { - if (ReactOnSuccess != null && !help) - await ctx.ReactWithAsync(ReactOnSuccess).ConfigureAwait(false); - } - else - { - if (ReactOnFailure != null && !help) - await ctx.ReactWithAsync(ReactOnFailure, $"{ReactOnFailure} {ctx.Message.Author.Mention} you do not have required permissions, this incident will be reported").ConfigureAwait(false); - } - return result; + if (ReactOnFailure != null && !help) + await ctx.ReactWithAsync(ReactOnFailure, $"{ReactOnFailure} {ctx.Message.Author.Mention} you do not have required permissions, this incident will be reported").ConfigureAwait(false); } + return result; } } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/LimitedToHelpChannel.cs b/CompatBot/Commands/Attributes/LimitedToHelpChannel.cs index 01f96e3e..6534baee 100644 --- a/CompatBot/Commands/Attributes/LimitedToHelpChannel.cs +++ b/CompatBot/Commands/Attributes/LimitedToHelpChannel.cs @@ -3,21 +3,20 @@ using System.Threading.Tasks; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; -namespace CompatBot.Commands.Attributes +namespace CompatBot.Commands.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class LimitedToHelpChannel: CheckBaseAttribute { - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class LimitedToHelpChannel: CheckBaseAttribute + public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) { - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) - { - if (ctx.Channel.IsPrivate || help) - return true; + if (ctx.Channel.IsPrivate || help) + return true; - if (ctx.Channel.Name.Equals("help", StringComparison.InvariantCultureIgnoreCase)) - return true; + if (ctx.Channel.Name.Equals("help", StringComparison.InvariantCultureIgnoreCase)) + return true; - await ctx.Channel.SendMessageAsync($"`{ctx.Prefix}{ctx.Command?.QualifiedName ?? ctx.RawArgumentString}` is limited to help channel and DMs").ConfigureAwait(false); - return false; - } + await ctx.Channel.SendMessageAsync($"`{ctx.Prefix}{ctx.Command?.QualifiedName ?? ctx.RawArgumentString}` is limited to help channel and DMs").ConfigureAwait(false); + return false; } } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/LimitedToOfftopicChannel.cs b/CompatBot/Commands/Attributes/LimitedToOfftopicChannel.cs index 3f78dcf2..c82ad536 100644 --- a/CompatBot/Commands/Attributes/LimitedToOfftopicChannel.cs +++ b/CompatBot/Commands/Attributes/LimitedToOfftopicChannel.cs @@ -7,40 +7,39 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands.Attributes +namespace CompatBot.Commands.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class LimitedToOfftopicChannel: CheckBaseAttribute { - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class LimitedToOfftopicChannel: CheckBaseAttribute + public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) { - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) - { - if (help || LimitedToSpamChannel.IsSpamChannel(ctx.Channel) || IsOfftopicChannel(ctx.Channel)) - return true; + if (help || LimitedToSpamChannel.IsSpamChannel(ctx.Channel) || IsOfftopicChannel(ctx.Channel)) + return true; - if (ctx.Command is null) - return false; - - try - { - var msgList = await ctx.Channel.GetMessagesCachedAsync(10).ConfigureAwait(false); - if (msgList.Any(m => m.Author.IsCurrent - && m.Content is string s - && s.Contains(ctx.Command.QualifiedName, StringComparison.InvariantCultureIgnoreCase))) - { - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - return false; // we just explained to use #bot-spam or DMs, can't help if people can't read - } - } - catch {} - - await ctx.Channel.SendMessageAsync($"`{ctx.Prefix}{ctx.Command.QualifiedName}` is limited to off-topic channels and DMs").ConfigureAwait(false); + if (ctx.Command is null) return false; - } - internal static bool IsOfftopicChannel(DiscordChannel channel) + try { - return channel.Name.Contains("off-topic", StringComparison.InvariantCultureIgnoreCase) - || channel.Name.Contains("offtopic", StringComparison.InvariantCultureIgnoreCase); + var msgList = await ctx.Channel.GetMessagesCachedAsync(10).ConfigureAwait(false); + if (msgList.Any(m => m.Author.IsCurrent + && m.Content is string s + && s.Contains(ctx.Command.QualifiedName, StringComparison.InvariantCultureIgnoreCase))) + { + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + return false; // we just explained to use #bot-spam or DMs, can't help if people can't read + } } + catch {} + + await ctx.Channel.SendMessageAsync($"`{ctx.Prefix}{ctx.Command.QualifiedName}` is limited to off-topic channels and DMs").ConfigureAwait(false); + return false; + } + + internal static bool IsOfftopicChannel(DiscordChannel channel) + { + return channel.Name.Contains("off-topic", StringComparison.InvariantCultureIgnoreCase) + || channel.Name.Contains("offtopic", StringComparison.InvariantCultureIgnoreCase); } } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/LimitedToSpamChannel.cs b/CompatBot/Commands/Attributes/LimitedToSpamChannel.cs index 968576c4..8ec2fcc2 100644 --- a/CompatBot/Commands/Attributes/LimitedToSpamChannel.cs +++ b/CompatBot/Commands/Attributes/LimitedToSpamChannel.cs @@ -7,40 +7,39 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands.Attributes +namespace CompatBot.Commands.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class LimitedToSpamChannel: CheckBaseAttribute { - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class LimitedToSpamChannel: CheckBaseAttribute + public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) { - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) - { - if (help || IsSpamChannel(ctx.Channel)) - return true; + if (help || IsSpamChannel(ctx.Channel)) + return true; - if (ctx.Command is null) - return false; - - try - { - var msgList = await ctx.Channel.GetMessagesCachedAsync(10).ConfigureAwait(false); - if (msgList.Any(m => m.Author.IsCurrent - && m.Content is string s - && s.Contains(ctx.Command.QualifiedName, StringComparison.InvariantCultureIgnoreCase))) - { - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - return false; // we just explained to use #bot-spam or DMs, can't help if people can't read - } - } - catch {} - - var spamChannel = await ctx.Client.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"`{ctx.Prefix}{ctx.Command.QualifiedName}` is limited to {spamChannel.Mention} and DMs").ConfigureAwait(false); + if (ctx.Command is null) return false; - } - internal static bool IsSpamChannel(DiscordChannel channel) + try { - return channel.IsPrivate || channel.Name.Contains("spam", StringComparison.InvariantCultureIgnoreCase); + var msgList = await ctx.Channel.GetMessagesCachedAsync(10).ConfigureAwait(false); + if (msgList.Any(m => m.Author.IsCurrent + && m.Content is string s + && s.Contains(ctx.Command.QualifiedName, StringComparison.InvariantCultureIgnoreCase))) + { + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + return false; // we just explained to use #bot-spam or DMs, can't help if people can't read + } } + catch {} + + var spamChannel = await ctx.Client.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"`{ctx.Prefix}{ctx.Command.QualifiedName}` is limited to {spamChannel.Mention} and DMs").ConfigureAwait(false); + return false; + } + + internal static bool IsSpamChannel(DiscordChannel channel) + { + return channel.IsPrivate || channel.Name.Contains("spam", StringComparison.InvariantCultureIgnoreCase); } } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/RequiresBotModRole.cs b/CompatBot/Commands/Attributes/RequiresBotModRole.cs index a89624df..d51ba4ba 100644 --- a/CompatBot/Commands/Attributes/RequiresBotModRole.cs +++ b/CompatBot/Commands/Attributes/RequiresBotModRole.cs @@ -3,16 +3,15 @@ using System.Threading.Tasks; using CompatBot.Database.Providers; using DSharpPlus.CommandsNext; -namespace CompatBot.Commands.Attributes -{ - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class RequiresBotModRole: CheckBaseAttributeWithReactions - { - public RequiresBotModRole() : base(reactOnFailure: Config.Reactions.Denied) { } +namespace CompatBot.Commands.Attributes; - protected override Task IsAllowed(CommandContext ctx, bool help) - { - return Task.FromResult(ModProvider.IsMod(ctx.User.Id)); - } +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class RequiresBotModRole: CheckBaseAttributeWithReactions +{ + public RequiresBotModRole() : base(reactOnFailure: Config.Reactions.Denied) { } + + protected override Task IsAllowed(CommandContext ctx, bool help) + { + return Task.FromResult(ModProvider.IsMod(ctx.User.Id)); } -} +} \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/RequiresBotSudoerRole.cs b/CompatBot/Commands/Attributes/RequiresBotSudoerRole.cs index 71e62e88..17fae9f6 100644 --- a/CompatBot/Commands/Attributes/RequiresBotSudoerRole.cs +++ b/CompatBot/Commands/Attributes/RequiresBotSudoerRole.cs @@ -3,14 +3,13 @@ using System.Threading.Tasks; using CompatBot.Utils; using DSharpPlus.CommandsNext; -namespace CompatBot.Commands.Attributes -{ - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class RequiresBotSudoerRole: CheckBaseAttributeWithReactions - { - public RequiresBotSudoerRole(): base(reactOnFailure: Config.Reactions.Denied) { } +namespace CompatBot.Commands.Attributes; - protected override Task IsAllowed(CommandContext ctx, bool help) - => Task.FromResult(ctx.User.IsModerator(ctx.Client, ctx.Guild)); - } +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class RequiresBotSudoerRole: CheckBaseAttributeWithReactions +{ + public RequiresBotSudoerRole(): base(reactOnFailure: Config.Reactions.Denied) { } + + protected override Task IsAllowed(CommandContext ctx, bool help) + => Task.FromResult(ctx.User.IsModerator(ctx.Client, ctx.Guild)); } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/RequiresDm.cs b/CompatBot/Commands/Attributes/RequiresDm.cs index 5d21814d..08e30a41 100644 --- a/CompatBot/Commands/Attributes/RequiresDm.cs +++ b/CompatBot/Commands/Attributes/RequiresDm.cs @@ -6,26 +6,25 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands.Attributes +namespace CompatBot.Commands.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class RequiresDm: CheckBaseAttribute { - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class RequiresDm: CheckBaseAttribute + private const string Source = "https://cdn.discordapp.com/attachments/417347469521715210/534798232858001418/24qx11.jpg"; + private static readonly Lazy Poster = new(() => { - private const string Source = "https://cdn.discordapp.com/attachments/417347469521715210/534798232858001418/24qx11.jpg"; - private static readonly Lazy Poster = new(() => - { - using var client = HttpClientFactory.Create(); - return client.GetByteArrayAsync(Source).ConfigureAwait(true).GetAwaiter().GetResult(); - }); + using var client = HttpClientFactory.Create(); + return client.GetByteArrayAsync(Source).ConfigureAwait(true).GetAwaiter().GetResult(); + }); - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) - { - if (ctx.Channel.IsPrivate || help) - return true; + public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) + { + if (ctx.Channel.IsPrivate || help) + return true; - await using var stream = new MemoryStream(Poster.Value); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("senpai_plz.jpg", stream)).ConfigureAwait(false); - return false; - } + await using var stream = new MemoryStream(Poster.Value); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("senpai_plz.jpg", stream)).ConfigureAwait(false); + return false; } } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/RequiresNotMedia.cs b/CompatBot/Commands/Attributes/RequiresNotMedia.cs index 32b576fa..150f7232 100644 --- a/CompatBot/Commands/Attributes/RequiresNotMedia.cs +++ b/CompatBot/Commands/Attributes/RequiresNotMedia.cs @@ -3,14 +3,13 @@ using System.Threading.Tasks; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; -namespace CompatBot.Commands.Attributes +namespace CompatBot.Commands.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class RequiresNotMedia: CheckBaseAttribute { - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class RequiresNotMedia: CheckBaseAttribute + public override Task ExecuteCheckAsync(CommandContext ctx, bool help) { - public override Task ExecuteCheckAsync(CommandContext ctx, bool help) - { - return Task.FromResult(ctx.Channel.Name != "media"); - } + return Task.FromResult(ctx.Channel.Name != "media"); } } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/RequiresSupporterRole.cs b/CompatBot/Commands/Attributes/RequiresSupporterRole.cs index ab18f779..51c5e054 100644 --- a/CompatBot/Commands/Attributes/RequiresSupporterRole.cs +++ b/CompatBot/Commands/Attributes/RequiresSupporterRole.cs @@ -3,16 +3,15 @@ using System.Threading.Tasks; using CompatBot.Utils; using DSharpPlus.CommandsNext; -namespace CompatBot.Commands.Attributes -{ - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class RequiresSupporterRole: CheckBaseAttributeWithReactions - { - public RequiresSupporterRole() : base(reactOnFailure: Config.Reactions.Denied) { } +namespace CompatBot.Commands.Attributes; - protected override Task IsAllowed(CommandContext ctx, bool help) - { - return Task.FromResult(ctx.User.IsWhitelisted(ctx.Client, ctx.Guild) || ctx.User.IsSupporter(ctx.Client, ctx.Guild)); - } +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class RequiresSupporterRole: CheckBaseAttributeWithReactions +{ + public RequiresSupporterRole() : base(reactOnFailure: Config.Reactions.Denied) { } + + protected override Task IsAllowed(CommandContext ctx, bool help) + { + return Task.FromResult(ctx.User.IsWhitelisted(ctx.Client, ctx.Guild) || ctx.User.IsSupporter(ctx.Client, ctx.Guild)); } } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/RequiresWhitelistedRole.cs b/CompatBot/Commands/Attributes/RequiresWhitelistedRole.cs index a7870b9d..c9e81aa9 100644 --- a/CompatBot/Commands/Attributes/RequiresWhitelistedRole.cs +++ b/CompatBot/Commands/Attributes/RequiresWhitelistedRole.cs @@ -3,16 +3,15 @@ using System.Threading.Tasks; using CompatBot.Utils; using DSharpPlus.CommandsNext; -namespace CompatBot.Commands.Attributes -{ - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class RequiresWhitelistedRole: CheckBaseAttributeWithReactions - { - public RequiresWhitelistedRole() : base(reactOnFailure: Config.Reactions.Denied) { } +namespace CompatBot.Commands.Attributes; - protected override Task IsAllowed(CommandContext ctx, bool help) - { - return Task.FromResult(ctx.User.IsWhitelisted(ctx.Client, ctx.Guild)); - } +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class RequiresWhitelistedRole: CheckBaseAttributeWithReactions +{ + public RequiresWhitelistedRole() : base(reactOnFailure: Config.Reactions.Denied) { } + + protected override Task IsAllowed(CommandContext ctx, bool help) + { + return Task.FromResult(ctx.User.IsWhitelisted(ctx.Client, ctx.Guild)); } } \ No newline at end of file diff --git a/CompatBot/Commands/Attributes/TriggersTyping.cs b/CompatBot/Commands/Attributes/TriggersTyping.cs index 8ecfcd94..a6cf3cbb 100644 --- a/CompatBot/Commands/Attributes/TriggersTyping.cs +++ b/CompatBot/Commands/Attributes/TriggersTyping.cs @@ -1,16 +1,15 @@ using System; using DSharpPlus.CommandsNext; -namespace CompatBot.Commands.Attributes -{ - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] - internal class TriggersTyping: Attribute - { - public bool InDmOnly { get; set; } +namespace CompatBot.Commands.Attributes; - public bool ExecuteCheck(CommandContext ctx) - { - return !InDmOnly || ctx.Channel.IsPrivate; - } +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] +internal class TriggersTyping: Attribute +{ + public bool InDmOnly { get; set; } + + public bool ExecuteCheck(CommandContext ctx) + { + return !InDmOnly || ctx.Channel.IsPrivate; } } \ No newline at end of file diff --git a/CompatBot/Commands/BaseCommandModuleCustom.cs b/CompatBot/Commands/BaseCommandModuleCustom.cs index 363f8950..a1d11c80 100644 --- a/CompatBot/Commands/BaseCommandModuleCustom.cs +++ b/CompatBot/Commands/BaseCommandModuleCustom.cs @@ -11,70 +11,69 @@ using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal class BaseCommandModuleCustom : BaseCommandModule { - internal class BaseCommandModuleCustom : BaseCommandModule + private DateTimeOffset executionStart; + + public override async Task BeforeExecutionAsync(CommandContext ctx) { - private DateTimeOffset executionStart; - - public override async Task BeforeExecutionAsync(CommandContext ctx) + executionStart = DateTimeOffset.UtcNow; + try { - executionStart = DateTimeOffset.UtcNow; - try + if (ctx.Prefix == Config.AutoRemoveCommandPrefix && ModProvider.IsMod(ctx.User.Id)) { - if (ctx.Prefix == Config.AutoRemoveCommandPrefix && ModProvider.IsMod(ctx.User.Id)) - { - DeletedMessagesMonitor.RemovedByBotCache.Set(ctx.Message.Id, true, DeletedMessagesMonitor.CacheRetainTime); - await ctx.Message.DeleteAsync().ConfigureAwait(false); - } + DeletedMessagesMonitor.RemovedByBotCache.Set(ctx.Message.Id, true, DeletedMessagesMonitor.CacheRetainTime); + await ctx.Message.DeleteAsync().ConfigureAwait(false); } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to delete command message with the autodelete command prefix"); - } - - if (ctx.Channel.Name == "media" && ctx.Command is { QualifiedName: not ("warn" or "report") }) - { - Config.Log.Info($"Ignoring command from {ctx.User.Username} (<@{ctx.User.Id}>) in #media: {ctx.Message.Content}"); - if (ctx.Member is DiscordMember member) - { - var dm = await member.CreateDmChannelAsync().ConfigureAwait(false); - await dm.SendMessageAsync($"Only `{Config.CommandPrefix}warn` and `{Config.CommandPrefix}report` are allowed in {ctx.Channel.Mention}").ConfigureAwait(false); - } - Config.TelemetryClient?.TrackRequest(ctx.Command.QualifiedName, executionStart, DateTimeOffset.UtcNow - executionStart, HttpStatusCode.Forbidden.ToString(), true); - throw new DSharpPlus.CommandsNext.Exceptions.ChecksFailedException(ctx.Command, ctx, new CheckBaseAttribute[] { new RequiresNotMedia() }); - } - - var disabledCmds = DisabledCommandsProvider.Get(); - if (ctx.Command is not null && disabledCmds.Contains(ctx.Command.QualifiedName) && !disabledCmds.Contains("*")) - { - await ctx.Channel.SendMessageAsync(embed: new DiscordEmbedBuilder {Color = Config.Colors.Maintenance, Description = "Command is currently disabled"}).ConfigureAwait(false); - Config.TelemetryClient?.TrackRequest(ctx.Command.QualifiedName, executionStart, DateTimeOffset.UtcNow - executionStart, HttpStatusCode.Locked.ToString(), true); - throw new DSharpPlus.CommandsNext.Exceptions.ChecksFailedException(ctx.Command, ctx, new CheckBaseAttribute[] {new RequiresDm()}); - } - - if (TriggersTyping(ctx)) - await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - - await base.BeforeExecutionAsync(ctx).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to delete command message with the autodelete command prefix"); } - public override async Task AfterExecutionAsync(CommandContext ctx) + if (ctx.Channel.Name == "media" && ctx.Command is { QualifiedName: not ("warn" or "report") }) { - if (ctx.Command?.QualifiedName is string qualifiedName) + Config.Log.Info($"Ignoring command from {ctx.User.Username} (<@{ctx.User.Id}>) in #media: {ctx.Message.Content}"); + if (ctx.Member is DiscordMember member) { - StatsStorage.CmdStatCache.TryGetValue(qualifiedName, out int counter); - StatsStorage.CmdStatCache.Set(qualifiedName, ++counter, StatsStorage.CacheTime); - Config.TelemetryClient?.TrackRequest(qualifiedName, executionStart, DateTimeOffset.UtcNow - executionStart, HttpStatusCode.OK.ToString(), true); + var dm = await member.CreateDmChannelAsync().ConfigureAwait(false); + await dm.SendMessageAsync($"Only `{Config.CommandPrefix}warn` and `{Config.CommandPrefix}report` are allowed in {ctx.Channel.Mention}").ConfigureAwait(false); } - - if (TriggersTyping(ctx)) - await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - - await base.AfterExecutionAsync(ctx).ConfigureAwait(false); + Config.TelemetryClient?.TrackRequest(ctx.Command.QualifiedName, executionStart, DateTimeOffset.UtcNow - executionStart, HttpStatusCode.Forbidden.ToString(), true); + throw new DSharpPlus.CommandsNext.Exceptions.ChecksFailedException(ctx.Command, ctx, new CheckBaseAttribute[] { new RequiresNotMedia() }); } - private static bool TriggersTyping(CommandContext ctx) - => ctx.Command?.CustomAttributes.OfType().FirstOrDefault() is TriggersTyping a && a.ExecuteCheck(ctx); + var disabledCmds = DisabledCommandsProvider.Get(); + if (ctx.Command is not null && disabledCmds.Contains(ctx.Command.QualifiedName) && !disabledCmds.Contains("*")) + { + await ctx.Channel.SendMessageAsync(embed: new DiscordEmbedBuilder {Color = Config.Colors.Maintenance, Description = "Command is currently disabled"}).ConfigureAwait(false); + Config.TelemetryClient?.TrackRequest(ctx.Command.QualifiedName, executionStart, DateTimeOffset.UtcNow - executionStart, HttpStatusCode.Locked.ToString(), true); + throw new DSharpPlus.CommandsNext.Exceptions.ChecksFailedException(ctx.Command, ctx, new CheckBaseAttribute[] {new RequiresDm()}); + } + + if (TriggersTyping(ctx)) + await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + + await base.BeforeExecutionAsync(ctx).ConfigureAwait(false); } + + public override async Task AfterExecutionAsync(CommandContext ctx) + { + if (ctx.Command?.QualifiedName is string qualifiedName) + { + StatsStorage.CmdStatCache.TryGetValue(qualifiedName, out int counter); + StatsStorage.CmdStatCache.Set(qualifiedName, ++counter, StatsStorage.CacheTime); + Config.TelemetryClient?.TrackRequest(qualifiedName, executionStart, DateTimeOffset.UtcNow - executionStart, HttpStatusCode.OK.ToString(), true); + } + + if (TriggersTyping(ctx)) + await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + + await base.AfterExecutionAsync(ctx).ConfigureAwait(false); + } + + private static bool TriggersTyping(CommandContext ctx) + => ctx.Command?.CustomAttributes.OfType().FirstOrDefault() is TriggersTyping a && a.ExecuteCheck(ctx); } \ No newline at end of file diff --git a/CompatBot/Commands/BotMath.cs b/CompatBot/Commands/BotMath.cs index 16f275e9..d38dbd2e 100644 --- a/CompatBot/Commands/BotMath.cs +++ b/CompatBot/Commands/BotMath.cs @@ -6,45 +6,44 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using org.mariuszgromada.math.mxparser; -namespace CompatBot.Commands -{ - [Group("math")] - [Description("Math, here you go Juhn. Use `math help` for syntax help")] - internal sealed class BotMath : BaseCommandModuleCustom - { - [GroupCommand, Priority(9)] - public async Task Expression(CommandContext ctx, [RemainingText, Description("Math expression")] string expression) - { - if (string.IsNullOrEmpty(expression)) - { - try - { - if (ctx.CommandsNext.FindCommand("math help", out _) is Command helpCmd) - { - var helpCtx = ctx.CommandsNext.CreateContext(ctx.Message, ctx.Prefix, helpCmd); - await helpCmd.ExecuteAsync(helpCtx).ConfigureAwait(false); - } - } - catch { } - return; - } +namespace CompatBot.Commands; - var result = @"Something went wrong ¯\\_(ツ)\_/¯" + "\nMath is hard, yo"; +[Group("math")] +[Description("Math, here you go Juhn. Use `math help` for syntax help")] +internal sealed class BotMath : BaseCommandModuleCustom +{ + [GroupCommand, Priority(9)] + public async Task Expression(CommandContext ctx, [RemainingText, Description("Math expression")] string expression) + { + if (string.IsNullOrEmpty(expression)) + { try { - var expr = new Expression(expression); - result = expr.calculate().ToString(CultureInfo.InvariantCulture); + if (ctx.CommandsNext.FindCommand("math help", out _) is Command helpCmd) + { + var helpCtx = ctx.CommandsNext.CreateContext(ctx.Message, ctx.Prefix, helpCmd); + await helpCmd.ExecuteAsync(helpCtx).ConfigureAwait(false); + } } - catch (Exception e) - { - Config.Log.Warn(e, "Math failed"); - } - await ctx.Channel.SendMessageAsync(result).ConfigureAwait(false); + catch { } + return; } - [Command("help"), LimitedToSpamChannel, Cooldown(1, 5, CooldownBucketType.Channel)] - [Description("General math expression help, or description of specific math word")] - public Task Help(CommandContext ctx) - => ctx.Channel.SendMessageAsync("Help for all the features and built-in constants and functions could be found at "); + var result = @"Something went wrong ¯\\_(ツ)\_/¯" + "\nMath is hard, yo"; + try + { + var expr = new Expression(expression); + result = expr.calculate().ToString(CultureInfo.InvariantCulture); + } + catch (Exception e) + { + Config.Log.Warn(e, "Math failed"); + } + await ctx.Channel.SendMessageAsync(result).ConfigureAwait(false); } + + [Command("help"), LimitedToSpamChannel, Cooldown(1, 5, CooldownBucketType.Channel)] + [Description("General math expression help, or description of specific math word")] + public Task Help(CommandContext ctx) + => ctx.Channel.SendMessageAsync("Help for all the features and built-in constants and functions could be found at "); } \ No newline at end of file diff --git a/CompatBot/Commands/BotStats.cs b/CompatBot/Commands/BotStats.cs index f57598ca..75b748e4 100644 --- a/CompatBot/Commands/BotStats.cs +++ b/CompatBot/Commands/BotStats.cs @@ -17,264 +17,263 @@ using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("stats")] +internal sealed class BotStats: BaseCommandModuleCustom { - [Group("stats")] - internal sealed class BotStats: BaseCommandModuleCustom + [GroupCommand] + [Description("Use to look at various runtime stats")] + public async Task Show(CommandContext ctx) { - [GroupCommand] - [Description("Use to look at various runtime stats")] - public async Task Show(CommandContext ctx) - { - var embed = new DiscordEmbedBuilder - { - Color = DiscordColor.Purple, - } - .AddField("Current Uptime", Config.Uptime.Elapsed.AsShortTimespan(), true) - .AddField("Discord Latency", $"{ctx.Client.Ping} ms", true); - if (!string.IsNullOrEmpty(Config.AzureComputerVisionKey)) - embed.AddField("Max OCR Queue", MediaScreenshotMonitor.MaxQueueLength.ToString(), true); - embed.AddField("API Tokens", GetConfiguredApiStats(), true) - .AddField("Memory Usage", $"GC: {GC.GetGCMemoryInfo().HeapSizeBytes.AsStorageUnit()}/{GC.GetGCMemoryInfo().TotalAvailableMemoryBytes.AsStorageUnit()}\n" + - $"API pools: L: {ApiConfig.MemoryStreamManager.LargePoolInUseSize.AsStorageUnit()}/{(ApiConfig.MemoryStreamManager.LargePoolInUseSize + ApiConfig.MemoryStreamManager.LargePoolFreeSize).AsStorageUnit()}" + - $" S: {ApiConfig.MemoryStreamManager.SmallPoolInUseSize.AsStorageUnit()}/{(ApiConfig.MemoryStreamManager.SmallPoolInUseSize + ApiConfig.MemoryStreamManager.SmallPoolFreeSize).AsStorageUnit()}\n" + - $"Bot pools: L: {Config.MemoryStreamManager.LargePoolInUseSize.AsStorageUnit()}/{(Config.MemoryStreamManager.LargePoolInUseSize + Config.MemoryStreamManager.LargePoolFreeSize).AsStorageUnit()}" + - $" S: {Config.MemoryStreamManager.SmallPoolInUseSize.AsStorageUnit()}/{(Config.MemoryStreamManager.SmallPoolInUseSize + Config.MemoryStreamManager.SmallPoolFreeSize).AsStorageUnit()}", true) - .AddField("GitHub Rate Limit", $"{GithubClient.Client.RateLimitRemaining} out of {GithubClient.Client.RateLimit} calls available\nReset in {(GithubClient.Client.RateLimitResetTime - DateTime.UtcNow).AsShortTimespan()}", true) - .AddField(".NET Info", $"{System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}\n" + - $"{(System.Runtime.GCSettings.IsServerGC ? "Server" : "Workstation")} GC Mode", true) - .AddField("Runtime Info", $"Confinement: {SandboxDetector.Detect()}\n" + - $"OS: {RuntimeInformation.OSDescription} {Environment.OSVersion}\n" + - $"CPUs: {Environment.ProcessorCount}\n" + - $"Time zones: {TimeParser.TimeZoneMap.Count} out of {TimeParser.TimeZoneAcronyms.Count} resolved, {TimeZoneInfo.GetSystemTimeZones().Count} total", true); - AppendPiracyStats(embed); - AppendCmdStats(embed); - AppendExplainStats(embed); - AppendGameLookupStats(embed); - AppendSyscallsStats(embed); - AppendPawStats(embed); + var embed = new DiscordEmbedBuilder + { + Color = DiscordColor.Purple, + } + .AddField("Current Uptime", Config.Uptime.Elapsed.AsShortTimespan(), true) + .AddField("Discord Latency", $"{ctx.Client.Ping} ms", true); + if (!string.IsNullOrEmpty(Config.AzureComputerVisionKey)) + embed.AddField("Max OCR Queue", MediaScreenshotMonitor.MaxQueueLength.ToString(), true); + embed.AddField("API Tokens", GetConfiguredApiStats(), true) + .AddField("Memory Usage", $"GC: {GC.GetGCMemoryInfo().HeapSizeBytes.AsStorageUnit()}/{GC.GetGCMemoryInfo().TotalAvailableMemoryBytes.AsStorageUnit()}\n" + + $"API pools: L: {ApiConfig.MemoryStreamManager.LargePoolInUseSize.AsStorageUnit()}/{(ApiConfig.MemoryStreamManager.LargePoolInUseSize + ApiConfig.MemoryStreamManager.LargePoolFreeSize).AsStorageUnit()}" + + $" S: {ApiConfig.MemoryStreamManager.SmallPoolInUseSize.AsStorageUnit()}/{(ApiConfig.MemoryStreamManager.SmallPoolInUseSize + ApiConfig.MemoryStreamManager.SmallPoolFreeSize).AsStorageUnit()}\n" + + $"Bot pools: L: {Config.MemoryStreamManager.LargePoolInUseSize.AsStorageUnit()}/{(Config.MemoryStreamManager.LargePoolInUseSize + Config.MemoryStreamManager.LargePoolFreeSize).AsStorageUnit()}" + + $" S: {Config.MemoryStreamManager.SmallPoolInUseSize.AsStorageUnit()}/{(Config.MemoryStreamManager.SmallPoolInUseSize + Config.MemoryStreamManager.SmallPoolFreeSize).AsStorageUnit()}", true) + .AddField("GitHub Rate Limit", $"{GithubClient.Client.RateLimitRemaining} out of {GithubClient.Client.RateLimit} calls available\nReset in {(GithubClient.Client.RateLimitResetTime - DateTime.UtcNow).AsShortTimespan()}", true) + .AddField(".NET Info", $"{System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}\n" + + $"{(System.Runtime.GCSettings.IsServerGC ? "Server" : "Workstation")} GC Mode", true) + .AddField("Runtime Info", $"Confinement: {SandboxDetector.Detect()}\n" + + $"OS: {RuntimeInformation.OSDescription} {Environment.OSVersion}\n" + + $"CPUs: {Environment.ProcessorCount}\n" + + $"Time zones: {TimeParser.TimeZoneMap.Count} out of {TimeParser.TimeZoneAcronyms.Count} resolved, {TimeZoneInfo.GetSystemTimeZones().Count} total", true); + AppendPiracyStats(embed); + AppendCmdStats(embed); + AppendExplainStats(embed); + AppendGameLookupStats(embed); + AppendSyscallsStats(embed); + AppendPawStats(embed); #if DEBUG - embed.WithFooter("Test Instance"); + embed.WithFooter("Test Instance"); #endif - var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); - await ch.SendMessageAsync(embed: embed).ConfigureAwait(false); - } - - private static string GetConfiguredApiStats() - { - return new StringBuilder() - .Append(File.Exists(Config.GoogleApiConfigPath) ? "✅" : "❌").AppendLine(" Google Drive") - .Append(string.IsNullOrEmpty(Config.AzureDevOpsToken) ? "❌" : "✅").AppendLine(" Azure DevOps") - .Append(string.IsNullOrEmpty(Config.AzureComputerVisionKey) ? "❌" : "✅").AppendLine(" Computer Vision") - .Append(string.IsNullOrEmpty(Config.AzureAppInsightsKey) ? "❌" : "✅").AppendLine(" AppInsights") - .Append(string.IsNullOrEmpty(Config.GithubToken) ? "❌" : "✅").AppendLine(" Github") - .ToString() - .Trim(); - - } - - private static void AppendPiracyStats(DiscordEmbedBuilder embed) - { - try - { - using var db = new BotDb(); - var timestamps = db.Warning - .Where(w => w.Timestamp.HasValue && !w.Retracted) - .OrderBy(w => w.Timestamp) - .Select(w => w.Timestamp!.Value) - .ToList(); - var firstWarnTimestamp = timestamps.FirstOrDefault(); - var previousTimestamp = firstWarnTimestamp; - var longestGapBetweenWarning = 0L; - long longestGapStart = 0L, longestGapEnd = 0L; - var span24H = TimeSpan.FromHours(24).Ticks; - var currentSpan = new Queue(); - long mostWarningsEnd = 0L, daysWithoutWarnings = 0L; - var mostWarnings = 0; - for (var i = 1; i < timestamps.Count; i++) - { - var currentTimestamp = timestamps[i]; - var newGap = currentTimestamp - previousTimestamp; - if (newGap > longestGapBetweenWarning) - { - longestGapBetweenWarning = newGap; - longestGapStart = previousTimestamp; - longestGapEnd = currentTimestamp; - } - if (newGap > span24H) - daysWithoutWarnings += newGap / span24H; - - currentSpan.Enqueue(currentTimestamp); - while (currentSpan.Count > 0 && currentTimestamp - currentSpan.Peek() > span24H) - currentSpan.Dequeue(); - if (currentSpan.Count > mostWarnings) - { - mostWarnings = currentSpan.Count; - currentSpan.Peek(); - mostWarningsEnd = currentTimestamp; - } - previousTimestamp = currentTimestamp; - } - - var utcNow = DateTime.UtcNow; - var yesterday = utcNow.AddDays(-1).Ticks; - var last24HWarnings = db.Warning.Where(w => w.Timestamp > yesterday && !w.Retracted).ToList(); - var warnCount = last24HWarnings.Count; - if (warnCount > mostWarnings) - { - mostWarnings = warnCount; - mostWarningsEnd = utcNow.Ticks; - } - var lastWarn = timestamps.Any() ? timestamps.Last() : (long?)null; - if (lastWarn.HasValue) - { - var currentGapBetweenWarnings = utcNow.Ticks - lastWarn.Value; - if (currentGapBetweenWarnings > longestGapBetweenWarning) - { - longestGapBetweenWarning = currentGapBetweenWarnings; - longestGapStart = lastWarn.Value; - longestGapEnd = utcNow.Ticks; - } - daysWithoutWarnings += currentGapBetweenWarnings / span24H; - } - // most warnings per 24h - var statsBuilder = new StringBuilder(); - var rightDate = longestGapEnd == utcNow.Ticks ? "now" : longestGapEnd.AsUtc().ToString("yyyy-MM-dd"); - if (longestGapBetweenWarning > 0) - statsBuilder.AppendLine($"Longest between warnings: **{TimeSpan.FromTicks(longestGapBetweenWarning).AsShortTimespan()}** between {longestGapStart.AsUtc():yyyy-MM-dd} and {rightDate}"); - rightDate = mostWarningsEnd == utcNow.Ticks ? "today" : $"on {mostWarningsEnd.AsUtc():yyyy-MM-dd}"; - if (mostWarnings > 0) - statsBuilder.AppendLine($"Most warnings in 24h: **{mostWarnings}** {rightDate}"); - if (daysWithoutWarnings > 0 && firstWarnTimestamp > 0) - statsBuilder.AppendLine($"Full days without warnings: **{daysWithoutWarnings}** out of {(DateTime.UtcNow - firstWarnTimestamp.AsUtc()).TotalDays:0}"); - { - statsBuilder.Append($"Warnings in the last 24h: **{warnCount}**"); - if (warnCount == 0) - statsBuilder.Append(' ').Append(BotReactionsHandler.RandomPositiveReaction); - statsBuilder.AppendLine(); - } - if (lastWarn.HasValue) - statsBuilder.AppendLine($"Time since last warning: {(DateTime.UtcNow - lastWarn.Value.AsUtc()).AsShortTimespan()}"); - embed.AddField("Warning Stats", statsBuilder.ToString().TrimEnd(), true); - } - catch (Exception e) - { - Config.Log.Warn(e); - } - } - - private static void AppendCmdStats(DiscordEmbedBuilder embed) - { - var commandStats = StatsStorage.CmdStatCache.GetCacheKeys(); - var sortedCommandStats = commandStats - .Select(c => (name: c, stat: StatsStorage.CmdStatCache.Get(c) as int?)) - .Where(c => c.stat.HasValue) - .OrderByDescending(c => c.stat) - .ToList(); - var totalCalls = sortedCommandStats.Sum(c => c.stat); - var top = sortedCommandStats.Take(5).ToList(); - if (top.Count == 0) - return; - - var statsBuilder = new StringBuilder(); - var n = 1; - foreach (var (name, stat) in top) - statsBuilder.AppendLine($"{n++}. {name} ({stat} call{(stat == 1 ? "" : "s")}, {stat * 100.0 / totalCalls:0.##}%)"); - statsBuilder.AppendLine($"Total commands executed: {totalCalls}"); - embed.AddField($"Top {top.Count} Recent Commands", statsBuilder.ToString().TrimEnd(), true); - } - - private static void AppendExplainStats(DiscordEmbedBuilder embed) - { - var terms = StatsStorage.ExplainStatCache.GetCacheKeys(); - var sortedTerms = terms - .Select(t => (term: t, stat: StatsStorage.ExplainStatCache.Get(t) as int?)) - .Where(t => t.stat.HasValue) - .OrderByDescending(t => t.stat) - .ToList(); - var totalExplains = sortedTerms.Sum(t => t.stat); - var top = sortedTerms.Take(5).ToList(); - if (top.Count == 0) - return; - - var statsBuilder = new StringBuilder(); - var n = 1; - foreach (var (term, stat) in top) - statsBuilder.AppendLine($"{n++}. {term} ({stat} display{(stat == 1 ? "" : "s")}, {stat * 100.0 / totalExplains:0.##}%)"); - statsBuilder.AppendLine($"Total explanations shown: {totalExplains}"); - embed.AddField($"Top {top.Count} Recent Explanations", statsBuilder.ToString().TrimEnd(), true); - } - - private static void AppendGameLookupStats(DiscordEmbedBuilder embed) - { - var gameTitles = StatsStorage.GameStatCache.GetCacheKeys(); - var sortedTitles = gameTitles - .Select(t => (title: t, stat: StatsStorage.GameStatCache.Get(t) as int?)) - .Where(t => t.stat.HasValue) - .OrderByDescending(t => t.stat) - .ToList(); - var totalLookups = sortedTitles.Sum(t => t.stat); - var top = sortedTitles.Take(5).ToList(); - if (top.Count == 0) - return; - - var statsBuilder = new StringBuilder(); - var n = 1; - foreach (var (title, stat) in top) - statsBuilder.AppendLine($"{n++}. {title.Trim(40)} ({stat} search{(stat == 1 ? "" : "es")}, {stat * 100.0 / totalLookups:0.##}%)"); - statsBuilder.AppendLine($"Total game lookups: {totalLookups}"); - embed.AddField($"Top {top.Count} Recent Game Lookups", statsBuilder.ToString().TrimEnd(), true); - } - - private static void AppendSyscallsStats(DiscordEmbedBuilder embed) - { - try - { - using var db = new ThumbnailDb(); - var syscallCount = db.SyscallInfo.AsNoTracking().Where(sci => sci.Function.StartsWith("sys_") || sci.Function.StartsWith("_sys_")).Distinct().Count(); - var totalFuncCount = db.SyscallInfo.AsNoTracking().Select(sci => sci.Function).Distinct().Count(); - var fwCallCount = totalFuncCount - syscallCount; - var gameCount = db.SyscallToProductMap.AsNoTracking().Select(m => m.ProductId).Distinct().Count(); - embed.AddField("SceCall Stats", - $"Tracked game IDs: {gameCount}\n" + - $"Tracked syscalls: {syscallCount} function{(syscallCount == 1 ? "" : "s")}\n" + - $"Tracked fw calls: {fwCallCount} function{(fwCallCount == 1 ? "" : "s")}\n", - true); - } - catch (Exception e) - { - Config.Log.Warn(e); - } - } - - private static void AppendPawStats(DiscordEmbedBuilder embed) - { - try - { - using var db = new BotDb(); - var kots = db.Kot.Count(); - var doggos = db.Doggo.Count(); - if (kots == 0 && doggos == 0) - return; - - var diff = kots > doggos ? (double)kots / doggos - 1.0 : (double)doggos / kots - 1.0; - var sign = double.IsNaN(diff) || (double.IsFinite(diff) && !double.IsNegative(diff) && diff < 0.05) ? ":" : (kots > doggos ? ">" : "<"); - var kot = sign switch - { - ">" => GoodKot[new Random().Next(GoodKot.Length)], - ":" => "🐱", - _ => MeanKot[new Random().Next(MeanKot.Length)] - }; - embed.AddField("🐾 Stats", $"{kot} {kots - 1} {sign} {doggos - 1} 🐶", true); - } - catch (Exception e) - { - Config.Log.Warn(e); - } - } - - internal static readonly string[] GoodDog = {"🐶", "🐕", "🐩", "🐕‍🦺",}; - internal static readonly string[] GoodKot = {"😸", "😺", "😻", "😽",}; - private static readonly string[] MeanKot = {"🙀", "😿", "😾",}; + var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); + await ch.SendMessageAsync(embed: embed).ConfigureAwait(false); } -} + + private static string GetConfiguredApiStats() + { + return new StringBuilder() + .Append(File.Exists(Config.GoogleApiConfigPath) ? "✅" : "❌").AppendLine(" Google Drive") + .Append(string.IsNullOrEmpty(Config.AzureDevOpsToken) ? "❌" : "✅").AppendLine(" Azure DevOps") + .Append(string.IsNullOrEmpty(Config.AzureComputerVisionKey) ? "❌" : "✅").AppendLine(" Computer Vision") + .Append(string.IsNullOrEmpty(Config.AzureAppInsightsKey) ? "❌" : "✅").AppendLine(" AppInsights") + .Append(string.IsNullOrEmpty(Config.GithubToken) ? "❌" : "✅").AppendLine(" Github") + .ToString() + .Trim(); + + } + + private static void AppendPiracyStats(DiscordEmbedBuilder embed) + { + try + { + using var db = new BotDb(); + var timestamps = db.Warning + .Where(w => w.Timestamp.HasValue && !w.Retracted) + .OrderBy(w => w.Timestamp) + .Select(w => w.Timestamp!.Value) + .ToList(); + var firstWarnTimestamp = timestamps.FirstOrDefault(); + var previousTimestamp = firstWarnTimestamp; + var longestGapBetweenWarning = 0L; + long longestGapStart = 0L, longestGapEnd = 0L; + var span24H = TimeSpan.FromHours(24).Ticks; + var currentSpan = new Queue(); + long mostWarningsEnd = 0L, daysWithoutWarnings = 0L; + var mostWarnings = 0; + for (var i = 1; i < timestamps.Count; i++) + { + var currentTimestamp = timestamps[i]; + var newGap = currentTimestamp - previousTimestamp; + if (newGap > longestGapBetweenWarning) + { + longestGapBetweenWarning = newGap; + longestGapStart = previousTimestamp; + longestGapEnd = currentTimestamp; + } + if (newGap > span24H) + daysWithoutWarnings += newGap / span24H; + + currentSpan.Enqueue(currentTimestamp); + while (currentSpan.Count > 0 && currentTimestamp - currentSpan.Peek() > span24H) + currentSpan.Dequeue(); + if (currentSpan.Count > mostWarnings) + { + mostWarnings = currentSpan.Count; + currentSpan.Peek(); + mostWarningsEnd = currentTimestamp; + } + previousTimestamp = currentTimestamp; + } + + var utcNow = DateTime.UtcNow; + var yesterday = utcNow.AddDays(-1).Ticks; + var last24HWarnings = db.Warning.Where(w => w.Timestamp > yesterday && !w.Retracted).ToList(); + var warnCount = last24HWarnings.Count; + if (warnCount > mostWarnings) + { + mostWarnings = warnCount; + mostWarningsEnd = utcNow.Ticks; + } + var lastWarn = timestamps.Any() ? timestamps.Last() : (long?)null; + if (lastWarn.HasValue) + { + var currentGapBetweenWarnings = utcNow.Ticks - lastWarn.Value; + if (currentGapBetweenWarnings > longestGapBetweenWarning) + { + longestGapBetweenWarning = currentGapBetweenWarnings; + longestGapStart = lastWarn.Value; + longestGapEnd = utcNow.Ticks; + } + daysWithoutWarnings += currentGapBetweenWarnings / span24H; + } + // most warnings per 24h + var statsBuilder = new StringBuilder(); + var rightDate = longestGapEnd == utcNow.Ticks ? "now" : longestGapEnd.AsUtc().ToString("yyyy-MM-dd"); + if (longestGapBetweenWarning > 0) + statsBuilder.AppendLine($"Longest between warnings: **{TimeSpan.FromTicks(longestGapBetweenWarning).AsShortTimespan()}** between {longestGapStart.AsUtc():yyyy-MM-dd} and {rightDate}"); + rightDate = mostWarningsEnd == utcNow.Ticks ? "today" : $"on {mostWarningsEnd.AsUtc():yyyy-MM-dd}"; + if (mostWarnings > 0) + statsBuilder.AppendLine($"Most warnings in 24h: **{mostWarnings}** {rightDate}"); + if (daysWithoutWarnings > 0 && firstWarnTimestamp > 0) + statsBuilder.AppendLine($"Full days without warnings: **{daysWithoutWarnings}** out of {(DateTime.UtcNow - firstWarnTimestamp.AsUtc()).TotalDays:0}"); + { + statsBuilder.Append($"Warnings in the last 24h: **{warnCount}**"); + if (warnCount == 0) + statsBuilder.Append(' ').Append(BotReactionsHandler.RandomPositiveReaction); + statsBuilder.AppendLine(); + } + if (lastWarn.HasValue) + statsBuilder.AppendLine($"Time since last warning: {(DateTime.UtcNow - lastWarn.Value.AsUtc()).AsShortTimespan()}"); + embed.AddField("Warning Stats", statsBuilder.ToString().TrimEnd(), true); + } + catch (Exception e) + { + Config.Log.Warn(e); + } + } + + private static void AppendCmdStats(DiscordEmbedBuilder embed) + { + var commandStats = StatsStorage.CmdStatCache.GetCacheKeys(); + var sortedCommandStats = commandStats + .Select(c => (name: c, stat: StatsStorage.CmdStatCache.Get(c) as int?)) + .Where(c => c.stat.HasValue) + .OrderByDescending(c => c.stat) + .ToList(); + var totalCalls = sortedCommandStats.Sum(c => c.stat); + var top = sortedCommandStats.Take(5).ToList(); + if (top.Count == 0) + return; + + var statsBuilder = new StringBuilder(); + var n = 1; + foreach (var (name, stat) in top) + statsBuilder.AppendLine($"{n++}. {name} ({stat} call{(stat == 1 ? "" : "s")}, {stat * 100.0 / totalCalls:0.##}%)"); + statsBuilder.AppendLine($"Total commands executed: {totalCalls}"); + embed.AddField($"Top {top.Count} Recent Commands", statsBuilder.ToString().TrimEnd(), true); + } + + private static void AppendExplainStats(DiscordEmbedBuilder embed) + { + var terms = StatsStorage.ExplainStatCache.GetCacheKeys(); + var sortedTerms = terms + .Select(t => (term: t, stat: StatsStorage.ExplainStatCache.Get(t) as int?)) + .Where(t => t.stat.HasValue) + .OrderByDescending(t => t.stat) + .ToList(); + var totalExplains = sortedTerms.Sum(t => t.stat); + var top = sortedTerms.Take(5).ToList(); + if (top.Count == 0) + return; + + var statsBuilder = new StringBuilder(); + var n = 1; + foreach (var (term, stat) in top) + statsBuilder.AppendLine($"{n++}. {term} ({stat} display{(stat == 1 ? "" : "s")}, {stat * 100.0 / totalExplains:0.##}%)"); + statsBuilder.AppendLine($"Total explanations shown: {totalExplains}"); + embed.AddField($"Top {top.Count} Recent Explanations", statsBuilder.ToString().TrimEnd(), true); + } + + private static void AppendGameLookupStats(DiscordEmbedBuilder embed) + { + var gameTitles = StatsStorage.GameStatCache.GetCacheKeys(); + var sortedTitles = gameTitles + .Select(t => (title: t, stat: StatsStorage.GameStatCache.Get(t) as int?)) + .Where(t => t.stat.HasValue) + .OrderByDescending(t => t.stat) + .ToList(); + var totalLookups = sortedTitles.Sum(t => t.stat); + var top = sortedTitles.Take(5).ToList(); + if (top.Count == 0) + return; + + var statsBuilder = new StringBuilder(); + var n = 1; + foreach (var (title, stat) in top) + statsBuilder.AppendLine($"{n++}. {title.Trim(40)} ({stat} search{(stat == 1 ? "" : "es")}, {stat * 100.0 / totalLookups:0.##}%)"); + statsBuilder.AppendLine($"Total game lookups: {totalLookups}"); + embed.AddField($"Top {top.Count} Recent Game Lookups", statsBuilder.ToString().TrimEnd(), true); + } + + private static void AppendSyscallsStats(DiscordEmbedBuilder embed) + { + try + { + using var db = new ThumbnailDb(); + var syscallCount = db.SyscallInfo.AsNoTracking().Where(sci => sci.Function.StartsWith("sys_") || sci.Function.StartsWith("_sys_")).Distinct().Count(); + var totalFuncCount = db.SyscallInfo.AsNoTracking().Select(sci => sci.Function).Distinct().Count(); + var fwCallCount = totalFuncCount - syscallCount; + var gameCount = db.SyscallToProductMap.AsNoTracking().Select(m => m.ProductId).Distinct().Count(); + embed.AddField("SceCall Stats", + $"Tracked game IDs: {gameCount}\n" + + $"Tracked syscalls: {syscallCount} function{(syscallCount == 1 ? "" : "s")}\n" + + $"Tracked fw calls: {fwCallCount} function{(fwCallCount == 1 ? "" : "s")}\n", + true); + } + catch (Exception e) + { + Config.Log.Warn(e); + } + } + + private static void AppendPawStats(DiscordEmbedBuilder embed) + { + try + { + using var db = new BotDb(); + var kots = db.Kot.Count(); + var doggos = db.Doggo.Count(); + if (kots == 0 && doggos == 0) + return; + + var diff = kots > doggos ? (double)kots / doggos - 1.0 : (double)doggos / kots - 1.0; + var sign = double.IsNaN(diff) || (double.IsFinite(diff) && !double.IsNegative(diff) && diff < 0.05) ? ":" : (kots > doggos ? ">" : "<"); + var kot = sign switch + { + ">" => GoodKot[new Random().Next(GoodKot.Length)], + ":" => "🐱", + _ => MeanKot[new Random().Next(MeanKot.Length)] + }; + embed.AddField("🐾 Stats", $"{kot} {kots - 1} {sign} {doggos - 1} 🐶", true); + } + catch (Exception e) + { + Config.Log.Warn(e); + } + } + + internal static readonly string[] GoodDog = {"🐶", "🐕", "🐩", "🐕‍🦺",}; + internal static readonly string[] GoodKot = {"😸", "😺", "😻", "😽",}; + private static readonly string[] MeanKot = {"🙀", "😿", "😾",}; +} \ No newline at end of file diff --git a/CompatBot/Commands/CommandsManagement.cs b/CompatBot/Commands/CommandsManagement.cs index 871aef96..c79fb168 100644 --- a/CompatBot/Commands/CommandsManagement.cs +++ b/CompatBot/Commands/CommandsManagement.cs @@ -9,189 +9,188 @@ using CompatBot.Utils; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("commands"), Aliases("command"), RequiresBotModRole] +[Description("Used to enable and disable bot commands at runtime")] +public sealed class CommandsManagement : BaseCommandModule { - [Group("commands"), Aliases("command"), RequiresBotModRole] - [Description("Used to enable and disable bot commands at runtime")] - public sealed class CommandsManagement : BaseCommandModule + [Command("list"), Aliases("show")] + [Description("Lists the disabled commands")] + public async Task List(CommandContext ctx) { - [Command("list"), Aliases("show")] - [Description("Lists the disabled commands")] - public async Task List(CommandContext ctx) + var list = DisabledCommandsProvider.Get(); + if (list.Count > 0) { - var list = DisabledCommandsProvider.Get(); - if (list.Count > 0) - { - var result = new StringBuilder("Currently disabled commands:").AppendLine().AppendLine("```"); - foreach (var cmd in list) - result.AppendLine(cmd); - await ctx.SendAutosplitMessageAsync(result.Append("```")).ConfigureAwait(false); - } - else - await ctx.Channel.SendMessageAsync("All commands are enabled").ConfigureAwait(false); + var result = new StringBuilder("Currently disabled commands:").AppendLine().AppendLine("```"); + foreach (var cmd in list) + result.AppendLine(cmd); + await ctx.SendAutosplitMessageAsync(result.Append("```")).ConfigureAwait(false); + } + else + await ctx.Channel.SendMessageAsync("All commands are enabled").ConfigureAwait(false); + } + + [Command("disable"), Aliases("add")] + [Description("Disables the specified command")] + public async Task Disable(CommandContext ctx, [RemainingText, Description("Fully qualified command to disable, e.g. `explain add` or `sudo mod *`")] string? command) + { + command ??= ""; + var isPrefix = command.EndsWith('*'); + if (isPrefix) + command = command.TrimEnd('*', ' '); + + if (string.IsNullOrEmpty(command) && !isPrefix) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "You need to specify the command").ConfigureAwait(false); + return; } - [Command("disable"), Aliases("add")] - [Description("Disables the specified command")] - public async Task Disable(CommandContext ctx, [RemainingText, Description("Fully qualified command to disable, e.g. `explain add` or `sudo mod *`")] string? command) + if (ctx.Command?.Parent is CommandGroup p && command.StartsWith(p.QualifiedName)) { - command ??= ""; - var isPrefix = command.EndsWith('*'); - if (isPrefix) - command = command.TrimEnd('*', ' '); - - if (string.IsNullOrEmpty(command) && !isPrefix) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "You need to specify the command").ConfigureAwait(false); - return; - } - - if (ctx.Command?.Parent is CommandGroup p && command.StartsWith(p.QualifiedName)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Cannot disable command management commands").ConfigureAwait(false); - return; - } - - var cmd = GetCommand(ctx, command); - if (isPrefix) - { - if (cmd == null && !string.IsNullOrEmpty(command)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown group `{command}`").ConfigureAwait(false); - return; - } - - try - { - if (cmd == null) - foreach (var c in ctx.CommandsNext.RegisteredCommands.Values) - DisableSubcommands(ctx, c); - else - DisableSubcommands(ctx, cmd); - if (ctx.Command?.Parent is CommandGroup parent && parent.QualifiedName.StartsWith(command)) - await ctx.Channel.SendMessageAsync("Some subcommands cannot be disabled").ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Success, $"Disabled `{command}` and all subcommands").ConfigureAwait(false); - await List(ctx).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - await ctx.Channel.SendMessageAsync("Error while disabling the group").ConfigureAwait(false); - } - } - else - { - if (cmd == null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown command `{command}`").ConfigureAwait(false); - return; - } - - command = cmd.QualifiedName; - DisabledCommandsProvider.Disable(command); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Disabled `{command}`").ConfigureAwait(false); - } + await ctx.ReactWithAsync(Config.Reactions.Failure, "Cannot disable command management commands").ConfigureAwait(false); + return; } - [Command("enable"), Aliases("reenable", "remove", "delete", "del", "clear")] - [Description("Enables the specified command")] - public async Task Enable(CommandContext ctx, [RemainingText, Description("Fully qualified command to enable, e.g. `explain add` or `sudo mod *`")] string? command) + var cmd = GetCommand(ctx, command); + if (isPrefix) { - if (command == "*") + if (cmd == null && !string.IsNullOrEmpty(command)) { - DisabledCommandsProvider.Clear(); - await ctx.ReactWithAsync(Config.Reactions.Success, "Enabled all the commands").ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown group `{command}`").ConfigureAwait(false); return; } - command ??= ""; - var isPrefix = command.EndsWith('*'); - if (isPrefix) - command = command.TrimEnd('*', ' '); - - if (string.IsNullOrEmpty(command)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "You need to specify the command").ConfigureAwait(false); - return; - } - - var cmd = GetCommand(ctx, command); - if (isPrefix) + try { if (cmd == null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown group `{command}`").ConfigureAwait(false); - return; - } - - try - { - EnableSubcommands(ctx, cmd); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Enabled `{command}` and all subcommands").ConfigureAwait(false); - await List(ctx).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - await ctx.Channel.SendMessageAsync("Error while enabling the group").ConfigureAwait(false); - } - } - else - { - if (cmd == null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown command `{command}`").ConfigureAwait(false); - return; - } - - command = cmd.QualifiedName; - DisabledCommandsProvider.Enable(command); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Enabled `{command}`").ConfigureAwait(false); - } - } - - private static Command? GetCommand(CommandContext ctx, string qualifiedName) - { - if (string.IsNullOrEmpty(qualifiedName)) - return null; - - var groups = (IReadOnlyList)ctx.CommandsNext.RegisteredCommands.Values.ToList(); - Command? result = null; - foreach (var cmdPart in qualifiedName.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - { - if (groups.FirstOrDefault(g => g.Name == cmdPart || g.Aliases.Any(a => a == cmdPart)) is Command c) - { - result = c; - if (c is CommandGroup subGroup) - groups = subGroup.Children; - } + foreach (var c in ctx.CommandsNext.RegisteredCommands.Values) + DisableSubcommands(ctx, c); else - return null; + DisableSubcommands(ctx, cmd); + if (ctx.Command?.Parent is CommandGroup parent && parent.QualifiedName.StartsWith(command)) + await ctx.Channel.SendMessageAsync("Some subcommands cannot be disabled").ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Success, $"Disabled `{command}` and all subcommands").ConfigureAwait(false); + await List(ctx).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e); + await ctx.Channel.SendMessageAsync("Error while disabling the group").ConfigureAwait(false); } - return result; } - - private static void DisableSubcommands(CommandContext ctx, Command cmd) + else { - if (ctx.Command?.Parent is not CommandGroup p || cmd.QualifiedName.StartsWith(p.QualifiedName)) + if (cmd == null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown command `{command}`").ConfigureAwait(false); return; + } - DisabledCommandsProvider.Disable(cmd.QualifiedName); - if (cmd is CommandGroup group) - foreach (var subCmd in group.Children) - DisableSubcommands(ctx, subCmd); - } - - private static void EnableSubcommands(CommandContext ctx, Command cmd) - { - if (ctx.Command?.Parent is not CommandGroup p || cmd.QualifiedName.StartsWith(p.QualifiedName)) - return; - - DisabledCommandsProvider.Enable(cmd.QualifiedName); - if (cmd is CommandGroup group) - foreach (var subCmd in group.Children) - EnableSubcommands(ctx, subCmd); + command = cmd.QualifiedName; + DisabledCommandsProvider.Disable(command); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Disabled `{command}`").ConfigureAwait(false); } } + + [Command("enable"), Aliases("reenable", "remove", "delete", "del", "clear")] + [Description("Enables the specified command")] + public async Task Enable(CommandContext ctx, [RemainingText, Description("Fully qualified command to enable, e.g. `explain add` or `sudo mod *`")] string? command) + { + if (command == "*") + { + DisabledCommandsProvider.Clear(); + await ctx.ReactWithAsync(Config.Reactions.Success, "Enabled all the commands").ConfigureAwait(false); + return; + } + + command ??= ""; + var isPrefix = command.EndsWith('*'); + if (isPrefix) + command = command.TrimEnd('*', ' '); + + if (string.IsNullOrEmpty(command)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "You need to specify the command").ConfigureAwait(false); + return; + } + + var cmd = GetCommand(ctx, command); + if (isPrefix) + { + if (cmd == null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown group `{command}`").ConfigureAwait(false); + return; + } + + try + { + EnableSubcommands(ctx, cmd); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Enabled `{command}` and all subcommands").ConfigureAwait(false); + await List(ctx).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e); + await ctx.Channel.SendMessageAsync("Error while enabling the group").ConfigureAwait(false); + } + } + else + { + if (cmd == null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown command `{command}`").ConfigureAwait(false); + return; + } + + command = cmd.QualifiedName; + DisabledCommandsProvider.Enable(command); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Enabled `{command}`").ConfigureAwait(false); + } + } + + private static Command? GetCommand(CommandContext ctx, string qualifiedName) + { + if (string.IsNullOrEmpty(qualifiedName)) + return null; + + var groups = (IReadOnlyList)ctx.CommandsNext.RegisteredCommands.Values.ToList(); + Command? result = null; + foreach (var cmdPart in qualifiedName.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (groups.FirstOrDefault(g => g.Name == cmdPart || g.Aliases.Any(a => a == cmdPart)) is Command c) + { + result = c; + if (c is CommandGroup subGroup) + groups = subGroup.Children; + } + else + return null; + } + return result; + } + + private static void DisableSubcommands(CommandContext ctx, Command cmd) + { + if (ctx.Command?.Parent is not CommandGroup p || cmd.QualifiedName.StartsWith(p.QualifiedName)) + return; + + DisabledCommandsProvider.Disable(cmd.QualifiedName); + if (cmd is CommandGroup group) + foreach (var subCmd in group.Children) + DisableSubcommands(ctx, subCmd); + } + + private static void EnableSubcommands(CommandContext ctx, Command cmd) + { + if (ctx.Command?.Parent is not CommandGroup p || cmd.QualifiedName.StartsWith(p.QualifiedName)) + return; + + DisabledCommandsProvider.Enable(cmd.QualifiedName); + if (cmd is CommandGroup group) + foreach (var subCmd in group.Children) + EnableSubcommands(ctx, subCmd); + } } \ No newline at end of file diff --git a/CompatBot/Commands/CompatList.cs b/CompatBot/Commands/CompatList.cs index df447c0d..4f04799a 100644 --- a/CompatBot/Commands/CompatList.cs +++ b/CompatBot/Commands/CompatList.cs @@ -30,695 +30,694 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.TeamFoundation.Build.WebApi; -namespace CompatBot.Commands -{ - internal sealed class CompatList : BaseCommandModuleCustom - { - private static readonly Client Client = new(); - private static readonly GithubClient.Client GithubClient = new(Config.GithubToken); - private static readonly SemaphoreSlim UpdateCheck = new(1, 1); - private static string? lastUpdateInfo, lastFullBuildNumber; - private const string Rpcs3UpdateStateKey = "Rpcs3UpdateState"; - private const string Rpcs3UpdateBuildKey = "Rpcs3UpdateBuild"; - private static UpdateInfo? cachedUpdateInfo; - private static readonly Regex UpdateVersionRegex = new(@"v(?\d+\.\d+\.\d+)-(?\d+)-(?[0-9a-f]+)\b", RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.ExplicitCapture); +namespace CompatBot.Commands; - static CompatList() +internal sealed class CompatList : BaseCommandModuleCustom +{ + private static readonly Client Client = new(); + private static readonly GithubClient.Client GithubClient = new(Config.GithubToken); + private static readonly SemaphoreSlim UpdateCheck = new(1, 1); + private static string? lastUpdateInfo, lastFullBuildNumber; + private const string Rpcs3UpdateStateKey = "Rpcs3UpdateState"; + private const string Rpcs3UpdateBuildKey = "Rpcs3UpdateBuild"; + private static UpdateInfo? cachedUpdateInfo; + private static readonly Regex UpdateVersionRegex = new(@"v(?\d+\.\d+\.\d+)-(?\d+)-(?[0-9a-f]+)\b", RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.ExplicitCapture); + + static CompatList() + { + using var db = new BotDb(); + lastUpdateInfo = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateStateKey)?.Value; + lastFullBuildNumber = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateBuildKey)?.Value; + //lastUpdateInfo = "8022"; + if (lastUpdateInfo is string strPr + && int.TryParse(strPr, out var pr)) { - using var db = new BotDb(); - lastUpdateInfo = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateStateKey)?.Value; - lastFullBuildNumber = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateBuildKey)?.Value; - //lastUpdateInfo = "8022"; - if (lastUpdateInfo is string strPr - && int.TryParse(strPr, out var pr)) + try { - try + var prInfo = GithubClient.GetPrInfoAsync(pr, Config.Cts.Token).ConfigureAwait(false).GetAwaiter().GetResult(); + cachedUpdateInfo = Client.GetUpdateAsync(Config.Cts.Token, prInfo?.MergeCommitSha).ConfigureAwait(false).GetAwaiter().GetResult(); + if (cachedUpdateInfo?.CurrentBuild != null) { - var prInfo = GithubClient.GetPrInfoAsync(pr, Config.Cts.Token).ConfigureAwait(false).GetAwaiter().GetResult(); - cachedUpdateInfo = Client.GetUpdateAsync(Config.Cts.Token, prInfo?.MergeCommitSha).ConfigureAwait(false).GetAwaiter().GetResult(); - if (cachedUpdateInfo?.CurrentBuild != null) - { - cachedUpdateInfo.LatestBuild = cachedUpdateInfo.CurrentBuild; - cachedUpdateInfo.CurrentBuild = null; - } + cachedUpdateInfo.LatestBuild = cachedUpdateInfo.CurrentBuild; + cachedUpdateInfo.CurrentBuild = null; } - catch { } } + catch { } + } + } + + [Command("compat"), Aliases("c", "compatibility")] + [Description("Searches the compatibility database, USE: !compat search term")] + public async Task Compat(CommandContext ctx, [RemainingText, Description("Game title to look up")] string? title) + { + title = title?.TrimEager().Truncate(40); + if (string.IsNullOrEmpty(title)) + { + var prompt = await ctx.Channel.SendMessageAsync($"{ctx.Message.Author.Mention} what game do you want to check?").ConfigureAwait(false); + var interact = ctx.Client.GetInteractivity(); + var response = await interact.WaitForMessageAsync(m => m.Author == ctx.Message.Author && m.Channel == ctx.Channel).ConfigureAwait(false); + if (string.IsNullOrEmpty(response.Result?.Content) || response.Result.Content.StartsWith(Config.CommandPrefix)) + { + await prompt.ModifyAsync("You should specify what you're looking for").ConfigureAwait(false); + return; + } + + DeletedMessagesMonitor.RemovedByBotCache.Set(prompt.Id, true, DeletedMessagesMonitor.CacheRetainTime); + await prompt.DeleteAsync().ConfigureAwait(false); + title = response.Result.Content.TrimEager().Truncate(40); } - [Command("compat"), Aliases("c", "compatibility")] - [Description("Searches the compatibility database, USE: !compat search term")] - public async Task Compat(CommandContext ctx, [RemainingText, Description("Game title to look up")] string? title) + if (!await DiscordInviteFilter.CheckMessageForInvitesAsync(ctx.Client, ctx.Message).ConfigureAwait(false)) + return; + + if (!await ContentFilter.IsClean(ctx.Client, ctx.Message).ConfigureAwait(false)) + return; + + var productCodes = ProductCodeLookup.GetProductIds(ctx.Message.Content); + if (productCodes.Any()) { - title = title?.TrimEager().Truncate(40); - if (string.IsNullOrEmpty(title)) + await ProductCodeLookup.LookupAndPostProductCodeEmbedAsync(ctx.Client, ctx.Message, ctx.Channel, productCodes).ConfigureAwait(false); + return; + } + + try + { + var requestBuilder = RequestBuilder.Start().SetSearch(title); + await DoRequestAndRespond(ctx, requestBuilder).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e, "Failed to get compat list info"); + } + } + + [Command("top"), LimitedToOfftopicChannel, Cooldown(1, 5, CooldownBucketType.Channel)] + [Description("Provides top game lists based on Metacritic and compatibility lists")] + public async Task Top(CommandContext ctx, + [Description("Number of entries in the list")] int number = 10, + [Description("One of `playable`, `ingame`, `intro`, `loadable`, or `Only`")] string status = "playable", + [Description("One of `both`, `critic`, or `user`")] string scoreType = "both") + { + status = status.ToLowerInvariant(); + scoreType = scoreType.ToLowerInvariant(); + + number = number.Clamp(1, 100); + var exactStatus = status.EndsWith("only"); + if (exactStatus) + status = status[..^4]; + if (!Enum.TryParse(status, true, out CompatStatus s)) + s = CompatStatus.Playable; + + await using var db = new ThumbnailDb(); + var queryBase = db.Thumbnail.AsNoTracking(); + if (exactStatus) + queryBase = queryBase.Where(g => g.CompatibilityStatus == s); + else + queryBase = queryBase.Where(g => g.CompatibilityStatus >= s); + queryBase = queryBase.Where(g => g.Metacritic != null).Include(t => t.Metacritic); + var query = scoreType switch + { + "critic" => queryBase.Where(t => t.Metacritic!.CriticScore > 0).AsEnumerable().Select(t => (title: t.Metacritic!.Title, score: t.Metacritic!.CriticScore!.Value, second: t.Metacritic.UserScore ?? t.Metacritic.CriticScore.Value)), + "user" => queryBase.Where(t => t.Metacritic!.UserScore > 0).AsEnumerable().Select(t => (title: t.Metacritic!.Title, score: t.Metacritic!.UserScore!.Value, second: t.Metacritic.CriticScore ?? t.Metacritic.UserScore.Value)), + _ => queryBase.AsEnumerable().Select(t => (title: t.Metacritic!.Title, score: Math.Max(t.Metacritic.CriticScore ?? 0, t.Metacritic.UserScore ?? 0), second: (byte)0)), + }; + var resultList = query.Where(i => i.score > 0) + .OrderByDescending(i => i.score) + .ThenByDescending(i => i.second) + .Distinct() + .Take(number) + .ToList(); + if (resultList.Count > 0) + { + var result = new StringBuilder($"Best {s.ToString().ToLower()}"); + if (exactStatus) + result.Append(" only"); + result.Append(" games"); + if (scoreType == "critic" || scoreType == "user") + result.Append($" according to {scoreType}s"); + result.AppendLine(":"); + foreach (var (title, score, _) in resultList) + result.AppendLine($"`{score:00}` {title}"); + await ctx.SendAutosplitMessageAsync(result, blockStart: null, blockEnd: null).ConfigureAwait(false); + } + else + await ctx.Channel.SendMessageAsync("Failed to generate list").ConfigureAwait(false); + } + + [Group("latest"), TriggersTyping] + [Description("Provides links to the latest RPCS3 build")] + [Cooldown(1, 30, CooldownBucketType.Channel)] + public sealed class UpdatesCheck: BaseCommandModuleCustom + { + [GroupCommand] + public Task Latest(CommandContext ctx) + { + return CheckForRpcs3Updates(ctx.Client, ctx.Channel); + } + + [Command("since")] + [Description("Show additional info about changes since specified update")] + public Task Since(CommandContext ctx, [Description("Commit hash of the update, such as `46abe0f31`")] string commit) + { + return CheckForRpcs3Updates(ctx.Client, ctx.Channel, commit); + } + + [Command("clear"), RequiresBotModRole] + [Description("Clears update info cache that is used to post new build announcements")] + public Task Clear(CommandContext ctx) + { + lastUpdateInfo = null; + lastFullBuildNumber = null; + return CheckForRpcs3Updates(ctx.Client, null); + } + + [Command("set"), RequiresBotModRole] + [Description("Sets update info cache that is used to check if new updates are available")] + public Task Set(CommandContext ctx, string lastUpdatePr) + { + lastUpdateInfo = lastUpdatePr; + lastFullBuildNumber = null; + return CheckForRpcs3Updates(ctx.Client, null); + } + + public static async Task CheckForRpcs3Updates(DiscordClient discordClient, DiscordChannel? channel, string? sinceCommit = null, DiscordMessage? emptyBotMsg = null) + { + var updateAnnouncement = channel is null; + var updateAnnouncementRestore = emptyBotMsg != null; + var info = await Client.GetUpdateAsync(Config.Cts.Token, sinceCommit).ConfigureAwait(false); + if (info?.ReturnCode != 1 && sinceCommit != null) + info = await Client.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false); + + if (updateAnnouncementRestore && info?.CurrentBuild != null) + info.LatestBuild = info.CurrentBuild; + var embed = await info.AsEmbedAsync(discordClient, updateAnnouncement).ConfigureAwait(false); + if (info == null || embed.Color.Value.Value == Config.Colors.Maintenance.Value) { - var prompt = await ctx.Channel.SendMessageAsync($"{ctx.Message.Author.Mention} what game do you want to check?").ConfigureAwait(false); - var interact = ctx.Client.GetInteractivity(); - var response = await interact.WaitForMessageAsync(m => m.Author == ctx.Message.Author && m.Channel == ctx.Channel).ConfigureAwait(false); - if (string.IsNullOrEmpty(response.Result?.Content) || response.Result.Content.StartsWith(Config.CommandPrefix)) + if (updateAnnouncementRestore) { - await prompt.ModifyAsync("You should specify what you're looking for").ConfigureAwait(false); - return; + Config.Log.Debug($"Failed to get update info for commit {sinceCommit}: {JsonSerializer.Serialize(info)}"); + return false; } - DeletedMessagesMonitor.RemovedByBotCache.Set(prompt.Id, true, DeletedMessagesMonitor.CacheRetainTime); - await prompt.DeleteAsync().ConfigureAwait(false); - title = response.Result.Content.TrimEager().Truncate(40); + embed = await cachedUpdateInfo.AsEmbedAsync(discordClient, updateAnnouncement).ConfigureAwait(false); } - - if (!await DiscordInviteFilter.CheckMessageForInvitesAsync(ctx.Client, ctx.Message).ConfigureAwait(false)) - return; - - if (!await ContentFilter.IsClean(ctx.Client, ctx.Message).ConfigureAwait(false)) - return; - - var productCodes = ProductCodeLookup.GetProductIds(ctx.Message.Content); - if (productCodes.Any()) + else if (!updateAnnouncementRestore) { - await ProductCodeLookup.LookupAndPostProductCodeEmbedAsync(ctx.Client, ctx.Message, ctx.Channel, productCodes).ConfigureAwait(false); - return; + if (cachedUpdateInfo?.LatestBuild?.Datetime is string previousBuildTimeStr + && info.LatestBuild?.Datetime is string newBuildTimeStr + && DateTime.TryParse(previousBuildTimeStr, out var previousBuildTime) + && DateTime.TryParse(newBuildTimeStr, out var newBuildTime) + && newBuildTime > previousBuildTime) + cachedUpdateInfo = info; } + if (!updateAnnouncement) + { + await channel!.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); + return true; + } + + if (updateAnnouncementRestore) + { + if (embed.Title == "Error") + return false; + + Config.Log.Debug($"Restoring update announcement for build {sinceCommit}: {embed.Title}\n{embed.Description}"); + await emptyBotMsg!.ModifyAsync(embed: embed.Build()).ConfigureAwait(false); + return true; + } + + var latestUpdatePr = info?.LatestBuild?.Pr?.ToString(); + var match = ( + from field in embed.Fields + let m = UpdateVersionRegex.Match(field.Value) + where m.Success + select m + ).FirstOrDefault(); + var latestUpdateBuild = match?.Groups["build"].Value; + + if (string.IsNullOrEmpty(latestUpdatePr) + || lastUpdateInfo == latestUpdatePr + || !await UpdateCheck.WaitAsync(0).ConfigureAwait(false)) + return false; try { - var requestBuilder = RequestBuilder.Start().SetSearch(title); - await DoRequestAndRespond(ctx, requestBuilder).ConfigureAwait(false); + if (!string.IsNullOrEmpty(lastFullBuildNumber) + && !string.IsNullOrEmpty(latestUpdateBuild) + && int.TryParse(lastFullBuildNumber, out var lastSaveBuild) + && int.TryParse(latestUpdateBuild, out var latestBuild) + && latestBuild <= lastSaveBuild) + return false; + + var compatChannel = await discordClient.GetChannelAsync(Config.BotChannelId).ConfigureAwait(false); + var botMember = discordClient.GetMember(compatChannel.Guild, discordClient.CurrentUser); + if (botMember == null) + return false; + + if (!compatChannel.PermissionsFor(botMember).HasPermission(Permissions.SendMessages)) + { + NewBuildsMonitor.Reset(); + return false; + } + + if (embed.Color.Value.Value == Config.Colors.Maintenance.Value) + return false; + + await CheckMissedBuildsBetween(discordClient, compatChannel, lastUpdateInfo, latestUpdatePr, Config.Cts.Token).ConfigureAwait(false); + + await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); + lastUpdateInfo = latestUpdatePr; + lastFullBuildNumber = latestUpdateBuild; + await using var db = new BotDb(); + var currentState = await db.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateStateKey).ConfigureAwait(false); + if (currentState == null) + await db.BotState.AddAsync(new() {Key = Rpcs3UpdateStateKey, Value = latestUpdatePr}).ConfigureAwait(false); + else + currentState.Value = latestUpdatePr; + var savedLastBuild = await db.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateBuildKey).ConfigureAwait(false); + if (savedLastBuild == null) + await db.BotState.AddAsync(new() {Key = Rpcs3UpdateBuildKey, Value = latestUpdateBuild}).ConfigureAwait(false); + else + savedLastBuild.Value = latestUpdateBuild; + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + NewBuildsMonitor.Reset(); + return true; } catch (Exception e) { - Config.Log.Error(e, "Failed to get compat list info"); + Config.Log.Warn(e, "Failed to check for RPCS3 update info"); } + finally + { + UpdateCheck.Release(); + } + return false; } - [Command("top"), LimitedToOfftopicChannel, Cooldown(1, 5, CooldownBucketType.Channel)] - [Description("Provides top game lists based on Metacritic and compatibility lists")] - public async Task Top(CommandContext ctx, - [Description("Number of entries in the list")] int number = 10, - [Description("One of `playable`, `ingame`, `intro`, `loadable`, or `Only`")] string status = "playable", - [Description("One of `both`, `critic`, or `user`")] string scoreType = "both") + private static async Task CheckMissedBuildsBetween(DiscordClient discordClient, DiscordChannel compatChannel, string? previousUpdatePr, string? latestUpdatePr, CancellationToken cancellationToken) { - status = status.ToLowerInvariant(); - scoreType = scoreType.ToLowerInvariant(); - - number = number.Clamp(1, 100); - var exactStatus = status.EndsWith("only"); - if (exactStatus) - status = status[..^4]; - if (!Enum.TryParse(status, true, out CompatStatus s)) - s = CompatStatus.Playable; - - await using var db = new ThumbnailDb(); - var queryBase = db.Thumbnail.AsNoTracking(); - if (exactStatus) - queryBase = queryBase.Where(g => g.CompatibilityStatus == s); - else - queryBase = queryBase.Where(g => g.CompatibilityStatus >= s); - queryBase = queryBase.Where(g => g.Metacritic != null).Include(t => t.Metacritic); - var query = scoreType switch - { - "critic" => queryBase.Where(t => t.Metacritic!.CriticScore > 0).AsEnumerable().Select(t => (title: t.Metacritic!.Title, score: t.Metacritic!.CriticScore!.Value, second: t.Metacritic.UserScore ?? t.Metacritic.CriticScore.Value)), - "user" => queryBase.Where(t => t.Metacritic!.UserScore > 0).AsEnumerable().Select(t => (title: t.Metacritic!.Title, score: t.Metacritic!.UserScore!.Value, second: t.Metacritic.CriticScore ?? t.Metacritic.UserScore.Value)), - _ => queryBase.AsEnumerable().Select(t => (title: t.Metacritic!.Title, score: Math.Max(t.Metacritic.CriticScore ?? 0, t.Metacritic.UserScore ?? 0), second: (byte)0)), - }; - var resultList = query.Where(i => i.score > 0) - .OrderByDescending(i => i.score) - .ThenByDescending(i => i.second) - .Distinct() - .Take(number) - .ToList(); - if (resultList.Count > 0) - { - var result = new StringBuilder($"Best {s.ToString().ToLower()}"); - if (exactStatus) - result.Append(" only"); - result.Append(" games"); - if (scoreType == "critic" || scoreType == "user") - result.Append($" according to {scoreType}s"); - result.AppendLine(":"); - foreach (var (title, score, _) in resultList) - result.AppendLine($"`{score:00}` {title}"); - await ctx.SendAutosplitMessageAsync(result, blockStart: null, blockEnd: null).ConfigureAwait(false); - } - else - await ctx.Channel.SendMessageAsync("Failed to generate list").ConfigureAwait(false); - } - - [Group("latest"), TriggersTyping] - [Description("Provides links to the latest RPCS3 build")] - [Cooldown(1, 30, CooldownBucketType.Channel)] - public sealed class UpdatesCheck: BaseCommandModuleCustom - { - [GroupCommand] - public Task Latest(CommandContext ctx) - { - return CheckForRpcs3Updates(ctx.Client, ctx.Channel); - } - - [Command("since")] - [Description("Show additional info about changes since specified update")] - public Task Since(CommandContext ctx, [Description("Commit hash of the update, such as `46abe0f31`")] string commit) - { - return CheckForRpcs3Updates(ctx.Client, ctx.Channel, commit); - } - - [Command("clear"), RequiresBotModRole] - [Description("Clears update info cache that is used to post new build announcements")] - public Task Clear(CommandContext ctx) - { - lastUpdateInfo = null; - lastFullBuildNumber = null; - return CheckForRpcs3Updates(ctx.Client, null); - } - - [Command("set"), RequiresBotModRole] - [Description("Sets update info cache that is used to check if new updates are available")] - public Task Set(CommandContext ctx, string lastUpdatePr) - { - lastUpdateInfo = lastUpdatePr; - lastFullBuildNumber = null; - return CheckForRpcs3Updates(ctx.Client, null); - } - - public static async Task CheckForRpcs3Updates(DiscordClient discordClient, DiscordChannel? channel, string? sinceCommit = null, DiscordMessage? emptyBotMsg = null) - { - var updateAnnouncement = channel is null; - var updateAnnouncementRestore = emptyBotMsg != null; - var info = await Client.GetUpdateAsync(Config.Cts.Token, sinceCommit).ConfigureAwait(false); - if (info?.ReturnCode != 1 && sinceCommit != null) - info = await Client.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false); - - if (updateAnnouncementRestore && info?.CurrentBuild != null) - info.LatestBuild = info.CurrentBuild; - var embed = await info.AsEmbedAsync(discordClient, updateAnnouncement).ConfigureAwait(false); - if (info == null || embed.Color.Value.Value == Config.Colors.Maintenance.Value) - { - if (updateAnnouncementRestore) - { - Config.Log.Debug($"Failed to get update info for commit {sinceCommit}: {JsonSerializer.Serialize(info)}"); - return false; - } - - embed = await cachedUpdateInfo.AsEmbedAsync(discordClient, updateAnnouncement).ConfigureAwait(false); - } - else if (!updateAnnouncementRestore) - { - if (cachedUpdateInfo?.LatestBuild?.Datetime is string previousBuildTimeStr - && info.LatestBuild?.Datetime is string newBuildTimeStr - && DateTime.TryParse(previousBuildTimeStr, out var previousBuildTime) - && DateTime.TryParse(newBuildTimeStr, out var newBuildTime) - && newBuildTime > previousBuildTime) - cachedUpdateInfo = info; - } - if (!updateAnnouncement) - { - await channel!.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); - return true; - } - - if (updateAnnouncementRestore) - { - if (embed.Title == "Error") - return false; - - Config.Log.Debug($"Restoring update announcement for build {sinceCommit}: {embed.Title}\n{embed.Description}"); - await emptyBotMsg!.ModifyAsync(embed: embed.Build()).ConfigureAwait(false); - return true; - } - - var latestUpdatePr = info?.LatestBuild?.Pr?.ToString(); - var match = ( - from field in embed.Fields - let m = UpdateVersionRegex.Match(field.Value) - where m.Success - select m - ).FirstOrDefault(); - var latestUpdateBuild = match?.Groups["build"].Value; - - if (string.IsNullOrEmpty(latestUpdatePr) - || lastUpdateInfo == latestUpdatePr - || !await UpdateCheck.WaitAsync(0).ConfigureAwait(false)) - return false; - - try - { - if (!string.IsNullOrEmpty(lastFullBuildNumber) - && !string.IsNullOrEmpty(latestUpdateBuild) - && int.TryParse(lastFullBuildNumber, out var lastSaveBuild) - && int.TryParse(latestUpdateBuild, out var latestBuild) - && latestBuild <= lastSaveBuild) - return false; - - var compatChannel = await discordClient.GetChannelAsync(Config.BotChannelId).ConfigureAwait(false); - var botMember = discordClient.GetMember(compatChannel.Guild, discordClient.CurrentUser); - if (botMember == null) - return false; - - if (!compatChannel.PermissionsFor(botMember).HasPermission(Permissions.SendMessages)) - { - NewBuildsMonitor.Reset(); - return false; - } - - if (embed.Color.Value.Value == Config.Colors.Maintenance.Value) - return false; - - await CheckMissedBuildsBetween(discordClient, compatChannel, lastUpdateInfo, latestUpdatePr, Config.Cts.Token).ConfigureAwait(false); - - await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); - lastUpdateInfo = latestUpdatePr; - lastFullBuildNumber = latestUpdateBuild; - await using var db = new BotDb(); - var currentState = await db.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateStateKey).ConfigureAwait(false); - if (currentState == null) - await db.BotState.AddAsync(new() {Key = Rpcs3UpdateStateKey, Value = latestUpdatePr}).ConfigureAwait(false); - else - currentState.Value = latestUpdatePr; - var savedLastBuild = await db.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateBuildKey).ConfigureAwait(false); - if (savedLastBuild == null) - await db.BotState.AddAsync(new() {Key = Rpcs3UpdateBuildKey, Value = latestUpdateBuild}).ConfigureAwait(false); - else - savedLastBuild.Value = latestUpdateBuild; - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - NewBuildsMonitor.Reset(); - return true; - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to check for RPCS3 update info"); - } - finally - { - UpdateCheck.Release(); - } - return false; - } - - private static async Task CheckMissedBuildsBetween(DiscordClient discordClient, DiscordChannel compatChannel, string? previousUpdatePr, string? latestUpdatePr, CancellationToken cancellationToken) - { - if (!int.TryParse(previousUpdatePr, out var oldestPr) - || !int.TryParse(latestUpdatePr, out var newestPr)) - return; - - var mergedPrs = await GithubClient.GetClosedPrsAsync(cancellationToken).ConfigureAwait(false); // this will cache 30 latest PRs - var newestPrCommit = await GithubClient.GetPrInfoAsync(newestPr, cancellationToken).ConfigureAwait(false); - var oldestPrCommit = await GithubClient.GetPrInfoAsync(oldestPr, cancellationToken).ConfigureAwait(false); - if (newestPrCommit?.MergedAt == null || oldestPrCommit?.MergedAt == null) - return; - - mergedPrs = mergedPrs?.Where(pri => pri.MergedAt.HasValue) - .OrderBy(pri => pri.MergedAt!.Value) - .SkipWhile(pri => pri.Number != oldestPr) - .Skip(1) - .TakeWhile(pri => pri.Number != newestPr) - .ToList(); - if (mergedPrs is null or {Count: 0}) - return; - - var failedBuilds = await Config.GetAzureDevOpsClient().GetMasterBuildsAsync( - oldestPrCommit.MergeCommitSha, - newestPrCommit.MergeCommitSha, - oldestPrCommit.MergedAt?.DateTime, - cancellationToken - ).ConfigureAwait(false); - foreach (var mergedPr in mergedPrs) - { - var updateInfo = await Client.GetUpdateAsync(cancellationToken, mergedPr.MergeCommitSha).ConfigureAwait(false) - ?? new UpdateInfo {ReturnCode = -1}; - if (updateInfo.ReturnCode == 0 || updateInfo.ReturnCode == 1) // latest or known build - { - updateInfo.LatestBuild = updateInfo.CurrentBuild; - updateInfo.CurrentBuild = null; - var embed = await updateInfo.AsEmbedAsync(discordClient, true).ConfigureAwait(false); - await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); - } - else if (updateInfo.ReturnCode == -1) // unknown build - { - var masterBuildInfo = failedBuilds?.FirstOrDefault(b => b.Commit?.Equals(mergedPr.MergeCommitSha, StringComparison.InvariantCultureIgnoreCase) is true); - var buildTime = masterBuildInfo?.FinishTime; - if (masterBuildInfo != null) - { - updateInfo = new() - { - ReturnCode = 1, - LatestBuild = new() - { - Datetime = buildTime?.ToString("yyyy-MM-dd HH:mm:ss"), - Pr = mergedPr.Number, - Windows = new() {Download = masterBuildInfo.WindowsBuildDownloadLink ?? ""}, - Linux = new() { Download = masterBuildInfo.LinuxBuildDownloadLink ?? "" }, - Mac = new() { Download = masterBuildInfo.MacBuildDownloadLink ?? "" }, - }, - }; - } - else - { - updateInfo = new() - { - ReturnCode = 1, - LatestBuild = new() - { - Pr = mergedPr.Number, - Windows = new() {Download = ""}, - Linux = new() { Download = "" }, - Mac = new() { Download = "" }, - }, - }; - } - var embed = await updateInfo.AsEmbedAsync(discordClient, true).ConfigureAwait(false); - embed.Color = Config.Colors.PrClosed; - embed.ClearFields(); - var reason = masterBuildInfo?.Result switch - { - BuildResult.Succeeded => "Built", - BuildResult.PartiallySucceeded => "Built", - BuildResult.Failed => "Failed to build", - BuildResult.Canceled => "Cancelled", - _ => null, - }; - if (buildTime.HasValue && reason != null) - embed.WithFooter($"{reason} on {buildTime:u} ({(DateTime.UtcNow - buildTime.Value).AsTimeDeltaDescription()} ago)"); - else - embed.WithFooter(reason ?? "Never built"); - await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); - } - } - } - } - - private static async Task DoRequestAndRespond(CommandContext ctx, RequestBuilder requestBuilder) - { - Config.Log.Info(requestBuilder.Build()); - CompatResult? result = null; - try - { - var remoteSearchTask = Client.GetCompatResultAsync(requestBuilder, Config.Cts.Token); - var localResult = GetLocalCompatResult(requestBuilder); - result = localResult; - var remoteResult = await remoteSearchTask.ConfigureAwait(false); - result = remoteResult?.Append(localResult); - } - catch - { - if (result == null) - { - await ctx.Channel.SendMessageAsync(embed: TitleInfo.CommunicationError.AsEmbed(null)).ConfigureAwait(false); - return; - } - } - -#if DEBUG - await Task.Delay(5_000).ConfigureAwait(false); -#endif - var channel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); - if (result?.Results?.Count == 1) - await ProductCodeLookup.LookupAndPostProductCodeEmbedAsync(ctx.Client, ctx.Message, ctx.Channel, new(result.Results.Keys)).ConfigureAwait(false); - else if (result != null) - foreach (var msg in FormatSearchResults(ctx, result)) - await channel.SendAutosplitMessageAsync(msg, blockStart: "", blockEnd: "").ConfigureAwait(false); - } - - internal static CompatResult GetLocalCompatResult(RequestBuilder requestBuilder) - { - var timer = Stopwatch.StartNew(); - var title = requestBuilder.Search; - using var db = new ThumbnailDb(); - var matches = db.Thumbnail - .AsNoTracking() - .AsEnumerable() - .Select(t => (thumb: t, coef: title.GetFuzzyCoefficientCached(t.Name))) - .OrderByDescending(i => i.coef) - .Take(requestBuilder.AmountRequested) - .ToList(); - var result = new CompatResult - { - RequestBuilder = requestBuilder, - ReturnCode = 0, - SearchTerm = requestBuilder.Search, - Results = matches.ToDictionary(i => i.thumb.ProductCode, i => new TitleInfo - { - Status = i.thumb.CompatibilityStatus?.ToString() ?? "Unknown", - Title = i.thumb.Name, - Date = i.thumb.CompatibilityChangeDate?.AsUtc().ToString("yyyy-MM-dd"), - }) - }; - timer.Stop(); - Config.Log.Debug($"Local compat list search time: {timer.ElapsedMilliseconds} ms"); - return result; - } - - private static IEnumerable FormatSearchResults(CommandContext ctx, CompatResult compatResult) - { - var returnCode = ApiConfig.ReturnCodes[compatResult.ReturnCode]; - var request = compatResult.RequestBuilder; - - if (returnCode.overrideAll) - yield return string.Format(returnCode.info, ctx.Message.Author.Mention); - else - { - var authorMention = ctx.Channel.IsPrivate ? "You" : ctx.Message.Author.Mention; - var result = new StringBuilder(); - result.AppendLine($"{authorMention} searched for: ***{request.Search?.Sanitize(replaceBackTicks: true)}***"); - if (request.Search?.Contains("persona", StringComparison.InvariantCultureIgnoreCase) is true - || request.Search?.Contains("p5", StringComparison.InvariantCultureIgnoreCase) is true) - result.AppendLine("Did you try searching for **__Unnamed__** instead?"); - else if (ctx.IsOnionLike() - && compatResult.Results.Values.Any(i => - i.Title.Contains("afrika", StringComparison.InvariantCultureIgnoreCase) - || i.Title.Contains("africa", StringComparison.InvariantCultureIgnoreCase)) - ) - { - var sqvat = ctx.Client.GetEmoji(":sqvat:", Config.Reactions.No); - result.AppendLine($"One day this meme will die {sqvat}"); - } - result.AppendFormat(returnCode.info, compatResult.SearchTerm); - yield return result.ToString(); - - result.Clear(); - - if (returnCode.displayResults) - { - var sortedList = compatResult.GetSortedList(); - var trimmedList = sortedList.Where(i => i.score > 0).ToList(); - if (trimmedList.Count > 0) - sortedList = trimmedList; - - var searchTerm = request.Search ?? @"¯\_(ツ)_/¯"; - var searchHits = sortedList.Where(t => t.score > 0.5 - || (t.info.Title?.StartsWith(searchTerm, StringComparison.InvariantCultureIgnoreCase) ?? false) - || (t.info.AlternativeTitle?.StartsWith(searchTerm, StringComparison.InvariantCultureIgnoreCase) ?? false)); - foreach (var title in searchHits.Select(t => t.info.Title).Distinct()) - { - StatsStorage.GameStatCache.TryGetValue(title, out int stat); - StatsStorage.GameStatCache.Set(title, ++stat, StatsStorage.CacheTime); - } - foreach (var resultInfo in sortedList.Take(request.AmountRequested)) - { - var info = resultInfo.AsString(); -#if DEBUG - info = $"{StringUtils.InvisibleSpacer}`{CompatApiResultUtils.GetScore(request.Search, resultInfo.info):0.000000}` {info}"; -#endif - result.AppendLine(info); - } - yield return result.ToString(); - } - } - } - - public static string FixGameTitleSearch(string title) - { - title = title.Trim(80); - if (title.Equals("persona 5", StringComparison.InvariantCultureIgnoreCase) - || title.Equals("p5", StringComparison.InvariantCultureIgnoreCase)) - title = "unnamed"; - else if (title.Equals("nnk", StringComparison.InvariantCultureIgnoreCase)) - title = "ni no kuni: wrath of the white witch"; - else if (title.Contains("mgs4", StringComparison.InvariantCultureIgnoreCase)) - title = title.Replace("mgs4", "mgs4gotp", StringComparison.InvariantCultureIgnoreCase); - else if (title.Contains("metal gear solid 4", StringComparison.InvariantCultureIgnoreCase)) - title = title.Replace("metal gear solid 4", "mgs4gotp", StringComparison.InvariantCultureIgnoreCase); - else if (title.Contains("lbp", StringComparison.InvariantCultureIgnoreCase)) - title = title.Replace("lbp", "littlebigplanet ", StringComparison.InvariantCultureIgnoreCase).TrimEnd(); - return title; - } - - public static async Task ImportCompatListAsync() - { - var list = await Client.GetCompatListSnapshotAsync(Config.Cts.Token).ConfigureAwait(false); - if (list is null) + if (!int.TryParse(previousUpdatePr, out var oldestPr) + || !int.TryParse(latestUpdatePr, out var newestPr)) return; - - await using var db = new ThumbnailDb(); - foreach (var kvp in list.Results) + + var mergedPrs = await GithubClient.GetClosedPrsAsync(cancellationToken).ConfigureAwait(false); // this will cache 30 latest PRs + var newestPrCommit = await GithubClient.GetPrInfoAsync(newestPr, cancellationToken).ConfigureAwait(false); + var oldestPrCommit = await GithubClient.GetPrInfoAsync(oldestPr, cancellationToken).ConfigureAwait(false); + if (newestPrCommit?.MergedAt == null || oldestPrCommit?.MergedAt == null) + return; + + mergedPrs = mergedPrs?.Where(pri => pri.MergedAt.HasValue) + .OrderBy(pri => pri.MergedAt!.Value) + .SkipWhile(pri => pri.Number != oldestPr) + .Skip(1) + .TakeWhile(pri => pri.Number != newestPr) + .ToList(); + if (mergedPrs is null or {Count: 0}) + return; + + var failedBuilds = await Config.GetAzureDevOpsClient().GetMasterBuildsAsync( + oldestPrCommit.MergeCommitSha, + newestPrCommit.MergeCommitSha, + oldestPrCommit.MergedAt?.DateTime, + cancellationToken + ).ConfigureAwait(false); + foreach (var mergedPr in mergedPrs) { - var (productCode, info) = kvp; - var dbItem = await db.Thumbnail.FirstOrDefaultAsync(t => t.ProductCode == productCode).ConfigureAwait(false); - if (dbItem is null - && await Client.GetCompatResultAsync(RequestBuilder.Start().SetSearch(productCode), Config.Cts.Token).ConfigureAwait(false) is {} compatItemSearchResult - && compatItemSearchResult.Results.TryGetValue(productCode, out var compatItem)) + var updateInfo = await Client.GetUpdateAsync(cancellationToken, mergedPr.MergeCommitSha).ConfigureAwait(false) + ?? new UpdateInfo {ReturnCode = -1}; + if (updateInfo.ReturnCode == 0 || updateInfo.ReturnCode == 1) // latest or known build { - dbItem = (await db.Thumbnail.AddAsync(new() + updateInfo.LatestBuild = updateInfo.CurrentBuild; + updateInfo.CurrentBuild = null; + var embed = await updateInfo.AsEmbedAsync(discordClient, true).ConfigureAwait(false); + await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); + } + else if (updateInfo.ReturnCode == -1) // unknown build + { + var masterBuildInfo = failedBuilds?.FirstOrDefault(b => b.Commit?.Equals(mergedPr.MergeCommitSha, StringComparison.InvariantCultureIgnoreCase) is true); + var buildTime = masterBuildInfo?.FinishTime; + if (masterBuildInfo != null) { - ProductCode = productCode, - Name = compatItem.Title, - }).ConfigureAwait(false)).Entity; - } - if (dbItem is null) - { - Config.Log.Debug($"Missing product code {productCode} in {nameof(ThumbnailDb)}"); - dbItem = new(); - } - if (Enum.TryParse(info.Status, out CompatStatus status)) - { - dbItem.CompatibilityStatus = status; - if (info.Date is string d - && DateTime.TryParseExact(d, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) - dbItem.CompatibilityChangeDate = date.Ticks; - } - else - Config.Log.Debug($"Failed to parse game compatibility status {info.Status}"); - } - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - } - - public static async Task ImportMetacriticScoresAsync() - { - var scoreJson = "metacritic_ps3.json"; - string json; - if (File.Exists(scoreJson)) - json = await File.ReadAllTextAsync(scoreJson).ConfigureAwait(false); - else - { - Config.Log.Warn($"Missing {scoreJson}, trying to get an online copy..."); - using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); - try - { - json = await httpClient.GetStringAsync($"https://raw.githubusercontent.com/RPCS3/discord-bot/master/{scoreJson}").ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Failed to get online copy of {scoreJson}"); - return; - } - } - - var scoreList = JsonSerializer.Deserialize>(json) ?? new(); - - Config.Log.Debug($"Importing {scoreList.Count} Metacritic items"); - var duplicates = new List(); - duplicates.AddRange( - scoreList.Where(i => i.Title.StartsWith("Disney") || i.Title.StartsWith("DreamWorks") || i.Title.StartsWith("PlayStation")) - .Select(i => i.WithTitle(i.Title.Split(' ', 2)[1])) - ); - duplicates.AddRange( - scoreList.Where(i => i.Title.Contains("A Telltale Game")) - .Select(i => i.WithTitle(i.Title.Substring(0, i.Title.IndexOf("A Telltale Game", StringComparison.Ordinal) - 1).TrimEnd(' ', '-', ':'))) - ); - duplicates.AddRange( - scoreList.Where(i => i.Title.StartsWith("Ratchet & Clank Future")) - .Select(i => i.WithTitle(i.Title.Replace("Ratchet & Clank Future", "Ratchet & Clank"))) - ); - duplicates.AddRange( - scoreList.Where(i => i.Title.StartsWith("MLB ")) - .Select(i => i.WithTitle($"Major League Baseball {i.Title[4..]}")) - ); - duplicates.AddRange( - scoreList.Where(i => i.Title.Contains("HAWX")) - .Select(i => i.WithTitle(i.Title.Replace("HAWX", "H.A.W.X"))) - ); - - await using var db = new ThumbnailDb(); - foreach (var mcScore in scoreList.Where(s => s.CriticScore > 0 || s.UserScore > 0)) - { - if (Config.Cts.IsCancellationRequested) - return; - - var item = db.Metacritic.FirstOrDefault(i => i.Title == mcScore.Title); - if (item == null) - item = (await db.Metacritic.AddAsync(mcScore).ConfigureAwait(false)).Entity; - else - { - item.CriticScore = mcScore.CriticScore; - item.UserScore = mcScore.UserScore; - item.Notes = mcScore.Notes; - } - await db.SaveChangesAsync().ConfigureAwait(false); - - var title = mcScore.Title; - var matches = db.Thumbnail - //.Where(t => t.MetacriticId == null) - .AsEnumerable() - .Select(t => (thumb: t, coef: t.Name.GetFuzzyCoefficientCached(title))) - .Where(i => i.coef > 0.90) - .OrderByDescending(i => i.coef) - .ToList(); - - if (Config.Cts.IsCancellationRequested) - return; - - if (matches.Any(m => m.coef > 0.99)) - matches = matches.Where(m => m.coef > 0.99).ToList(); - else if (matches.Any(m => m.coef > 0.95)) - matches = matches.Where(m => m.coef > 0.95).ToList(); - - if (matches.Count == 0) - { - try - { - var searchResult = await Client.GetCompatResultAsync(RequestBuilder.Start().SetSearch(title), Config.Cts.Token).ConfigureAwait(false); - var compatListMatches = searchResult?.Results - .Select(i => (productCode: i.Key, titleInfo: i.Value, coef: Math.Max(title.GetFuzzyCoefficientCached(i.Value.Title), title.GetFuzzyCoefficientCached(i.Value.AlternativeTitle)))) - .Where(i => i.coef > 0.85) - .OrderByDescending(i => i.coef) - .ToList() - ?? new List<(string productCode, TitleInfo titleInfo, double coef)>(); - if (compatListMatches.Any(i => i.coef > 0.99)) - compatListMatches = compatListMatches.Where(i => i.coef > 0.99).ToList(); - else if (compatListMatches.Any(i => i.coef > 0.95)) - compatListMatches = compatListMatches.Where(i => i.coef > 0.95).ToList(); - else if (compatListMatches.Any(i => i.coef > 0.90)) - compatListMatches = compatListMatches.Where(i => i.coef > 0.90).ToList(); - foreach ((string productCode, TitleInfo titleInfo, double coef) in compatListMatches) + updateInfo = new() { - var dbItem = await db.Thumbnail.FirstOrDefaultAsync(i => i.ProductCode == productCode).ConfigureAwait(false); - if (dbItem is null) - dbItem = (await db.Thumbnail.AddAsync(new() - { - ProductCode = productCode, - Name = titleInfo.Title, - }).ConfigureAwait(false)).Entity; - if (dbItem is null) - continue; - - dbItem.Name = titleInfo.Title; - if (Enum.TryParse(titleInfo.Status, out CompatStatus status)) - dbItem.CompatibilityStatus = status; - if (DateTime.TryParseExact(titleInfo.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) - dbItem.CompatibilityChangeDate = date.Ticks; - matches.Add((dbItem, coef)); - } - await db.SaveChangesAsync().ConfigureAwait(false); + ReturnCode = 1, + LatestBuild = new() + { + Datetime = buildTime?.ToString("yyyy-MM-dd HH:mm:ss"), + Pr = mergedPr.Number, + Windows = new() {Download = masterBuildInfo.WindowsBuildDownloadLink ?? ""}, + Linux = new() { Download = masterBuildInfo.LinuxBuildDownloadLink ?? "" }, + Mac = new() { Download = masterBuildInfo.MacBuildDownloadLink ?? "" }, + }, + }; } - catch (Exception e) + else { - Config.Log.Warn(e); + updateInfo = new() + { + ReturnCode = 1, + LatestBuild = new() + { + Pr = mergedPr.Number, + Windows = new() {Download = ""}, + Linux = new() { Download = "" }, + Mac = new() { Download = "" }, + }, + }; } - } - matches = matches.Where(i => !Regex.IsMatch(i.thumb.Name ?? "", @"\b(demo|trial)\b", RegexOptions.IgnoreCase | RegexOptions.Singleline)).ToList(); - //var bestMatch = matches.FirstOrDefault(); - //Config.Log.Trace($"Best title match for [{item.Title}] is [{bestMatch.thumb.Name}] with score {bestMatch.coef:0.0000}"); - if (matches.Count > 0) - { - Config.Log.Trace($"Matched metacritic [{item.Title}] to compat titles: {string.Join(", ", matches.Select(m => $"[{m.thumb.Name}]"))}"); - foreach (var (thumb, _) in matches) - thumb.Metacritic = item; - await db.SaveChangesAsync().ConfigureAwait(false); - } - else - { - Config.Log.Warn($"Failed to find a single match for metacritic [{item.Title}]"); + var embed = await updateInfo.AsEmbedAsync(discordClient, true).ConfigureAwait(false); + embed.Color = Config.Colors.PrClosed; + embed.ClearFields(); + var reason = masterBuildInfo?.Result switch + { + BuildResult.Succeeded => "Built", + BuildResult.PartiallySucceeded => "Built", + BuildResult.Failed => "Failed to build", + BuildResult.Canceled => "Cancelled", + _ => null, + }; + if (buildTime.HasValue && reason != null) + embed.WithFooter($"{reason} on {buildTime:u} ({(DateTime.UtcNow - buildTime.Value).AsTimeDeltaDescription()} ago)"); + else + embed.WithFooter(reason ?? "Never built"); + await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); } } } } + + private static async Task DoRequestAndRespond(CommandContext ctx, RequestBuilder requestBuilder) + { + Config.Log.Info(requestBuilder.Build()); + CompatResult? result = null; + try + { + var remoteSearchTask = Client.GetCompatResultAsync(requestBuilder, Config.Cts.Token); + var localResult = GetLocalCompatResult(requestBuilder); + result = localResult; + var remoteResult = await remoteSearchTask.ConfigureAwait(false); + result = remoteResult?.Append(localResult); + } + catch + { + if (result == null) + { + await ctx.Channel.SendMessageAsync(embed: TitleInfo.CommunicationError.AsEmbed(null)).ConfigureAwait(false); + return; + } + } + +#if DEBUG + await Task.Delay(5_000).ConfigureAwait(false); +#endif + var channel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); + if (result?.Results?.Count == 1) + await ProductCodeLookup.LookupAndPostProductCodeEmbedAsync(ctx.Client, ctx.Message, ctx.Channel, new(result.Results.Keys)).ConfigureAwait(false); + else if (result != null) + foreach (var msg in FormatSearchResults(ctx, result)) + await channel.SendAutosplitMessageAsync(msg, blockStart: "", blockEnd: "").ConfigureAwait(false); + } + + internal static CompatResult GetLocalCompatResult(RequestBuilder requestBuilder) + { + var timer = Stopwatch.StartNew(); + var title = requestBuilder.Search; + using var db = new ThumbnailDb(); + var matches = db.Thumbnail + .AsNoTracking() + .AsEnumerable() + .Select(t => (thumb: t, coef: title.GetFuzzyCoefficientCached(t.Name))) + .OrderByDescending(i => i.coef) + .Take(requestBuilder.AmountRequested) + .ToList(); + var result = new CompatResult + { + RequestBuilder = requestBuilder, + ReturnCode = 0, + SearchTerm = requestBuilder.Search, + Results = matches.ToDictionary(i => i.thumb.ProductCode, i => new TitleInfo + { + Status = i.thumb.CompatibilityStatus?.ToString() ?? "Unknown", + Title = i.thumb.Name, + Date = i.thumb.CompatibilityChangeDate?.AsUtc().ToString("yyyy-MM-dd"), + }) + }; + timer.Stop(); + Config.Log.Debug($"Local compat list search time: {timer.ElapsedMilliseconds} ms"); + return result; + } + + private static IEnumerable FormatSearchResults(CommandContext ctx, CompatResult compatResult) + { + var returnCode = ApiConfig.ReturnCodes[compatResult.ReturnCode]; + var request = compatResult.RequestBuilder; + + if (returnCode.overrideAll) + yield return string.Format(returnCode.info, ctx.Message.Author.Mention); + else + { + var authorMention = ctx.Channel.IsPrivate ? "You" : ctx.Message.Author.Mention; + var result = new StringBuilder(); + result.AppendLine($"{authorMention} searched for: ***{request.Search?.Sanitize(replaceBackTicks: true)}***"); + if (request.Search?.Contains("persona", StringComparison.InvariantCultureIgnoreCase) is true + || request.Search?.Contains("p5", StringComparison.InvariantCultureIgnoreCase) is true) + result.AppendLine("Did you try searching for **__Unnamed__** instead?"); + else if (ctx.IsOnionLike() + && compatResult.Results.Values.Any(i => + i.Title.Contains("afrika", StringComparison.InvariantCultureIgnoreCase) + || i.Title.Contains("africa", StringComparison.InvariantCultureIgnoreCase)) + ) + { + var sqvat = ctx.Client.GetEmoji(":sqvat:", Config.Reactions.No); + result.AppendLine($"One day this meme will die {sqvat}"); + } + result.AppendFormat(returnCode.info, compatResult.SearchTerm); + yield return result.ToString(); + + result.Clear(); + + if (returnCode.displayResults) + { + var sortedList = compatResult.GetSortedList(); + var trimmedList = sortedList.Where(i => i.score > 0).ToList(); + if (trimmedList.Count > 0) + sortedList = trimmedList; + + var searchTerm = request.Search ?? @"¯\_(ツ)_/¯"; + var searchHits = sortedList.Where(t => t.score > 0.5 + || (t.info.Title?.StartsWith(searchTerm, StringComparison.InvariantCultureIgnoreCase) ?? false) + || (t.info.AlternativeTitle?.StartsWith(searchTerm, StringComparison.InvariantCultureIgnoreCase) ?? false)); + foreach (var title in searchHits.Select(t => t.info.Title).Distinct()) + { + StatsStorage.GameStatCache.TryGetValue(title, out int stat); + StatsStorage.GameStatCache.Set(title, ++stat, StatsStorage.CacheTime); + } + foreach (var resultInfo in sortedList.Take(request.AmountRequested)) + { + var info = resultInfo.AsString(); +#if DEBUG + info = $"{StringUtils.InvisibleSpacer}`{CompatApiResultUtils.GetScore(request.Search, resultInfo.info):0.000000}` {info}"; +#endif + result.AppendLine(info); + } + yield return result.ToString(); + } + } + } + + public static string FixGameTitleSearch(string title) + { + title = title.Trim(80); + if (title.Equals("persona 5", StringComparison.InvariantCultureIgnoreCase) + || title.Equals("p5", StringComparison.InvariantCultureIgnoreCase)) + title = "unnamed"; + else if (title.Equals("nnk", StringComparison.InvariantCultureIgnoreCase)) + title = "ni no kuni: wrath of the white witch"; + else if (title.Contains("mgs4", StringComparison.InvariantCultureIgnoreCase)) + title = title.Replace("mgs4", "mgs4gotp", StringComparison.InvariantCultureIgnoreCase); + else if (title.Contains("metal gear solid 4", StringComparison.InvariantCultureIgnoreCase)) + title = title.Replace("metal gear solid 4", "mgs4gotp", StringComparison.InvariantCultureIgnoreCase); + else if (title.Contains("lbp", StringComparison.InvariantCultureIgnoreCase)) + title = title.Replace("lbp", "littlebigplanet ", StringComparison.InvariantCultureIgnoreCase).TrimEnd(); + return title; + } + + public static async Task ImportCompatListAsync() + { + var list = await Client.GetCompatListSnapshotAsync(Config.Cts.Token).ConfigureAwait(false); + if (list is null) + return; + + await using var db = new ThumbnailDb(); + foreach (var kvp in list.Results) + { + var (productCode, info) = kvp; + var dbItem = await db.Thumbnail.FirstOrDefaultAsync(t => t.ProductCode == productCode).ConfigureAwait(false); + if (dbItem is null + && await Client.GetCompatResultAsync(RequestBuilder.Start().SetSearch(productCode), Config.Cts.Token).ConfigureAwait(false) is {} compatItemSearchResult + && compatItemSearchResult.Results.TryGetValue(productCode, out var compatItem)) + { + dbItem = (await db.Thumbnail.AddAsync(new() + { + ProductCode = productCode, + Name = compatItem.Title, + }).ConfigureAwait(false)).Entity; + } + if (dbItem is null) + { + Config.Log.Debug($"Missing product code {productCode} in {nameof(ThumbnailDb)}"); + dbItem = new(); + } + if (Enum.TryParse(info.Status, out CompatStatus status)) + { + dbItem.CompatibilityStatus = status; + if (info.Date is string d + && DateTime.TryParseExact(d, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + dbItem.CompatibilityChangeDate = date.Ticks; + } + else + Config.Log.Debug($"Failed to parse game compatibility status {info.Status}"); + } + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + } + + public static async Task ImportMetacriticScoresAsync() + { + var scoreJson = "metacritic_ps3.json"; + string json; + if (File.Exists(scoreJson)) + json = await File.ReadAllTextAsync(scoreJson).ConfigureAwait(false); + else + { + Config.Log.Warn($"Missing {scoreJson}, trying to get an online copy..."); + using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); + try + { + json = await httpClient.GetStringAsync($"https://raw.githubusercontent.com/RPCS3/discord-bot/master/{scoreJson}").ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, $"Failed to get online copy of {scoreJson}"); + return; + } + } + + var scoreList = JsonSerializer.Deserialize>(json) ?? new(); + + Config.Log.Debug($"Importing {scoreList.Count} Metacritic items"); + var duplicates = new List(); + duplicates.AddRange( + scoreList.Where(i => i.Title.StartsWith("Disney") || i.Title.StartsWith("DreamWorks") || i.Title.StartsWith("PlayStation")) + .Select(i => i.WithTitle(i.Title.Split(' ', 2)[1])) + ); + duplicates.AddRange( + scoreList.Where(i => i.Title.Contains("A Telltale Game")) + .Select(i => i.WithTitle(i.Title.Substring(0, i.Title.IndexOf("A Telltale Game", StringComparison.Ordinal) - 1).TrimEnd(' ', '-', ':'))) + ); + duplicates.AddRange( + scoreList.Where(i => i.Title.StartsWith("Ratchet & Clank Future")) + .Select(i => i.WithTitle(i.Title.Replace("Ratchet & Clank Future", "Ratchet & Clank"))) + ); + duplicates.AddRange( + scoreList.Where(i => i.Title.StartsWith("MLB ")) + .Select(i => i.WithTitle($"Major League Baseball {i.Title[4..]}")) + ); + duplicates.AddRange( + scoreList.Where(i => i.Title.Contains("HAWX")) + .Select(i => i.WithTitle(i.Title.Replace("HAWX", "H.A.W.X"))) + ); + + await using var db = new ThumbnailDb(); + foreach (var mcScore in scoreList.Where(s => s.CriticScore > 0 || s.UserScore > 0)) + { + if (Config.Cts.IsCancellationRequested) + return; + + var item = db.Metacritic.FirstOrDefault(i => i.Title == mcScore.Title); + if (item == null) + item = (await db.Metacritic.AddAsync(mcScore).ConfigureAwait(false)).Entity; + else + { + item.CriticScore = mcScore.CriticScore; + item.UserScore = mcScore.UserScore; + item.Notes = mcScore.Notes; + } + await db.SaveChangesAsync().ConfigureAwait(false); + + var title = mcScore.Title; + var matches = db.Thumbnail + //.Where(t => t.MetacriticId == null) + .AsEnumerable() + .Select(t => (thumb: t, coef: t.Name.GetFuzzyCoefficientCached(title))) + .Where(i => i.coef > 0.90) + .OrderByDescending(i => i.coef) + .ToList(); + + if (Config.Cts.IsCancellationRequested) + return; + + if (matches.Any(m => m.coef > 0.99)) + matches = matches.Where(m => m.coef > 0.99).ToList(); + else if (matches.Any(m => m.coef > 0.95)) + matches = matches.Where(m => m.coef > 0.95).ToList(); + + if (matches.Count == 0) + { + try + { + var searchResult = await Client.GetCompatResultAsync(RequestBuilder.Start().SetSearch(title), Config.Cts.Token).ConfigureAwait(false); + var compatListMatches = searchResult?.Results + .Select(i => (productCode: i.Key, titleInfo: i.Value, coef: Math.Max(title.GetFuzzyCoefficientCached(i.Value.Title), title.GetFuzzyCoefficientCached(i.Value.AlternativeTitle)))) + .Where(i => i.coef > 0.85) + .OrderByDescending(i => i.coef) + .ToList() + ?? new List<(string productCode, TitleInfo titleInfo, double coef)>(); + if (compatListMatches.Any(i => i.coef > 0.99)) + compatListMatches = compatListMatches.Where(i => i.coef > 0.99).ToList(); + else if (compatListMatches.Any(i => i.coef > 0.95)) + compatListMatches = compatListMatches.Where(i => i.coef > 0.95).ToList(); + else if (compatListMatches.Any(i => i.coef > 0.90)) + compatListMatches = compatListMatches.Where(i => i.coef > 0.90).ToList(); + foreach ((string productCode, TitleInfo titleInfo, double coef) in compatListMatches) + { + var dbItem = await db.Thumbnail.FirstOrDefaultAsync(i => i.ProductCode == productCode).ConfigureAwait(false); + if (dbItem is null) + dbItem = (await db.Thumbnail.AddAsync(new() + { + ProductCode = productCode, + Name = titleInfo.Title, + }).ConfigureAwait(false)).Entity; + if (dbItem is null) + continue; + + dbItem.Name = titleInfo.Title; + if (Enum.TryParse(titleInfo.Status, out CompatStatus status)) + dbItem.CompatibilityStatus = status; + if (DateTime.TryParseExact(titleInfo.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + dbItem.CompatibilityChangeDate = date.Ticks; + matches.Add((dbItem, coef)); + } + await db.SaveChangesAsync().ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e); + } + } + matches = matches.Where(i => !Regex.IsMatch(i.thumb.Name ?? "", @"\b(demo|trial)\b", RegexOptions.IgnoreCase | RegexOptions.Singleline)).ToList(); + //var bestMatch = matches.FirstOrDefault(); + //Config.Log.Trace($"Best title match for [{item.Title}] is [{bestMatch.thumb.Name}] with score {bestMatch.coef:0.0000}"); + if (matches.Count > 0) + { + Config.Log.Trace($"Matched metacritic [{item.Title}] to compat titles: {string.Join(", ", matches.Select(m => $"[{m.thumb.Name}]"))}"); + foreach (var (thumb, _) in matches) + thumb.Metacritic = item; + await db.SaveChangesAsync().ConfigureAwait(false); + } + else + { + Config.Log.Warn($"Failed to find a single match for metacritic [{item.Title}]"); + } + } + } } \ No newline at end of file diff --git a/CompatBot/Commands/ContentFilters.cs b/CompatBot/Commands/ContentFilters.cs index 3dd4a6cb..dc2685f4 100644 --- a/CompatBot/Commands/ContentFilters.cs +++ b/CompatBot/Commands/ContentFilters.cs @@ -25,908 +25,907 @@ using DSharpPlus.Interactivity.Extensions; using Microsoft.EntityFrameworkCore; using Exception = System.Exception; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("filters"), Aliases("piracy", "filter"), RequiresBotSudoerRole, RequiresDm] +[Description("Used to manage content filters. **Works only in DM**")] +internal sealed class ContentFilters: BaseCommandModuleCustom { - [Group("filters"), Aliases("piracy", "filter"), RequiresBotSudoerRole, RequiresDm] - [Description("Used to manage content filters. **Works only in DM**")] - internal sealed class ContentFilters: BaseCommandModuleCustom + private static readonly TimeSpan InteractTimeout = TimeSpan.FromMinutes(5); + private static readonly char[] Separators = {' ', ',', ';', '|'}; + private static readonly SemaphoreSlim ImportLock = new(1, 1); + + [Command("list")] + [Description("Lists all filters")] + public async Task List(CommandContext ctx) { - private static readonly TimeSpan InteractTimeout = TimeSpan.FromMinutes(5); - private static readonly char[] Separators = {' ', ',', ';', '|'}; - private static readonly SemaphoreSlim ImportLock = new(1, 1); - - [Command("list")] - [Description("Lists all filters")] - public async Task List(CommandContext ctx) + var table = new AsciiTable( + new AsciiColumn("ID", alignToRight: true), + new AsciiColumn("Trigger"), + new AsciiColumn("Validation", maxWidth: 2048), + new AsciiColumn("Context", maxWidth: 4096), + new AsciiColumn("Actions"), + new AsciiColumn("Custom message", maxWidth: 2048) + ); + await using var db = new BotDb(); + var duplicates = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + var filters = db.Piracystring.Where(ps => !ps.Disabled).AsNoTracking().AsEnumerable().OrderBy(ps => ps.String.ToUpperInvariant()).ToList(); + var nonUniqueTriggers = ( + from f in filters + group f by f.String.ToUpperInvariant() + into g + where g.Count() > 1 + select g.Key + ).ToList(); + foreach (var t in nonUniqueTriggers) { - var table = new AsciiTable( - new AsciiColumn("ID", alignToRight: true), - new AsciiColumn("Trigger"), - new AsciiColumn("Validation", maxWidth: 2048), - new AsciiColumn("Context", maxWidth: 4096), - new AsciiColumn("Actions"), - new AsciiColumn("Custom message", maxWidth: 2048) + var duplicateFilters = filters.Where(ps => ps.String.Equals(t, StringComparison.InvariantCultureIgnoreCase)).ToList(); + foreach (FilterContext fctx in Enum.GetValues(typeof(FilterContext))) + { + if (duplicateFilters.Count(f => (f.Context & fctx) == fctx) > 1) + { + if (duplicates.TryGetValue(t, out var fctxDup)) + duplicates[t] = fctxDup | fctx; + else + duplicates[t] = fctx; + } + } + } + foreach (var item in filters) + { + var ctxl = item.Context.ToString(); + if (duplicates.Count > 0 + && duplicates.TryGetValue(item.String, out var fctx) + && (item.Context & fctx) != 0) + ctxl = "❗ " + ctxl; + table.Add( + item.Id.ToString(), + item.String.Sanitize(), + item.ValidatingRegex ?? "", + ctxl, + item.Actions.ToFlagsString(), + item.CustomMessage ?? "" ); - await using var db = new BotDb(); - var duplicates = new Dictionary(StringComparer.InvariantCultureIgnoreCase); - var filters = db.Piracystring.Where(ps => !ps.Disabled).AsNoTracking().AsEnumerable().OrderBy(ps => ps.String.ToUpperInvariant()).ToList(); - var nonUniqueTriggers = ( - from f in filters - group f by f.String.ToUpperInvariant() - into g - where g.Count() > 1 - select g.Key - ).ToList(); - foreach (var t in nonUniqueTriggers) - { - var duplicateFilters = filters.Where(ps => ps.String.Equals(t, StringComparison.InvariantCultureIgnoreCase)).ToList(); - foreach (FilterContext fctx in Enum.GetValues(typeof(FilterContext))) - { - if (duplicateFilters.Count(f => (f.Context & fctx) == fctx) > 1) - { - if (duplicates.TryGetValue(t, out var fctxDup)) - duplicates[t] = fctxDup | fctx; - else - duplicates[t] = fctx; - } - } - } - foreach (var item in filters) - { - var ctxl = item.Context.ToString(); - if (duplicates.Count > 0 - && duplicates.TryGetValue(item.String, out var fctx) - && (item.Context & fctx) != 0) - ctxl = "❗ " + ctxl; - table.Add( - item.Id.ToString(), - item.String.Sanitize(), - item.ValidatingRegex ?? "", - ctxl, - item.Actions.ToFlagsString(), - item.CustomMessage ?? "" - ); - } - var result = new StringBuilder(table.ToString(false)).AppendLine() - .AppendLine(FilterActionExtensions.GetLegend("")); - await using var output = Config.MemoryStreamManager.GetStream(); - //await using (var gzip = new GZipStream(output, CompressionLevel.Optimal, true)) - await using (var writer = new StreamWriter(output, leaveOpen: true)) - await writer.WriteAsync(result.ToString()).ConfigureAwait(false); - output.Seek(0, SeekOrigin.Begin); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("filters.txt", output)).ConfigureAwait(false); + } + var result = new StringBuilder(table.ToString(false)).AppendLine() + .AppendLine(FilterActionExtensions.GetLegend("")); + await using var output = Config.MemoryStreamManager.GetStream(); + //await using (var gzip = new GZipStream(output, CompressionLevel.Optimal, true)) + await using (var writer = new StreamWriter(output, leaveOpen: true)) + await writer.WriteAsync(result.ToString()).ConfigureAwait(false); + output.Seek(0, SeekOrigin.Begin); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("filters.txt", output)).ConfigureAwait(false); + } + + [Command("add"), Aliases("create")] + [Description("Adds a new content filter")] + public async Task Add(CommandContext ctx, [RemainingText, Description("A plain string to match")] string trigger) + { + await using var db = new BotDb(); + Piracystring? filter; + if (string.IsNullOrEmpty(trigger)) + filter = new Piracystring(); + else + { + filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String == trigger && ps.Disabled).ConfigureAwait(false); + if (filter == null) + filter = new Piracystring {String = trigger}; + else + filter.Disabled = false; + } + var isNewFilter = filter.Id == default; + if (isNewFilter) + { + filter.Context = FilterContext.Chat | FilterContext.Log; + filter.Actions = FilterAction.RemoveContent | FilterAction.IssueWarning | FilterAction.SendMessage; } - [Command("add"), Aliases("create")] - [Description("Adds a new content filter")] - public async Task Add(CommandContext ctx, [RemainingText, Description("A plain string to match")] string trigger) + var (success, msg) = await EditFilterPropertiesAsync(ctx, db, filter).ConfigureAwait(false); + if (success) { - await using var db = new BotDb(); - Piracystring? filter; - if (string.IsNullOrEmpty(trigger)) - filter = new Piracystring(); - else - { - filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String == trigger && ps.Disabled).ConfigureAwait(false); - if (filter == null) - filter = new Piracystring {String = trigger}; - else - filter.Disabled = false; - } - var isNewFilter = filter.Id == default; if (isNewFilter) - { - filter.Context = FilterContext.Chat | FilterContext.Log; - filter.Actions = FilterAction.RemoveContent | FilterAction.IssueWarning | FilterAction.SendMessage; - } - - var (success, msg) = await EditFilterPropertiesAsync(ctx, db, filter).ConfigureAwait(false); - if (success) - { - if (isNewFilter) - await db.Piracystring.AddAsync(filter).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatFilter(filter).WithTitle("Created a new content filter #" + filter.Id)).ConfigureAwait(false); - var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); - var reportMsg = $"{member?.GetMentionWithNickname()} added a new content filter: `{filter.String.Sanitize()}`"; - if (!string.IsNullOrEmpty(filter.ValidatingRegex)) - reportMsg += $"\nValidation: `{filter.ValidatingRegex}`"; - await ctx.Client.ReportAsync("🆕 Content filter created", reportMsg, null, ReportSeverity.Low).ConfigureAwait(false); - ContentFilter.RebuildMatcher(); - } - else - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Content filter creation aborted").ConfigureAwait(false); - } - - [Command("import"), RequiresBotSudoerRole] - [Description("Import suspicious strings for a certain dump collection from attached dat file (zip is fine)")] - public async Task Import(CommandContext ctx) - { - if (ctx.Message.Attachments.Count == 0) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "No attached DAT file", true).ConfigureAwait(false); - return; - } - - if (!await ImportLock.WaitAsync(0)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Another import is in progress", true).ConfigureAwait(false); - return; - } - var count = 0; - try - { - var attachment = ctx.Message.Attachments[0]; - await using var datStream = Config.MemoryStreamManager.GetStream(); - using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); - await using var attachmentStream = await httpClient.GetStreamAsync(attachment.Url, Config.Cts.Token).ConfigureAwait(false); - if (attachment.FileName.ToLower().EndsWith(".dat")) - await attachmentStream.CopyToAsync(datStream, Config.Cts.Token).ConfigureAwait(false); - else if (attachment.FileName.ToLower().EndsWith(".zip")) - { - using var zipStream = new ZipArchive(attachmentStream, ZipArchiveMode.Read); - var entry = zipStream.Entries.FirstOrDefault(e => e.Name.ToLower().EndsWith(".dat")); - if (entry is null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Attached ZIP file doesn't contain DAT file", true).ConfigureAwait(false); - return; - } - - await using var entryStream = entry.Open(); - await entryStream.CopyToAsync(datStream, Config.Cts.Token).ConfigureAwait(false); - } - else - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Attached file is not recognized", true).ConfigureAwait(false); - return; - } - - datStream.Seek(0, SeekOrigin.Begin); - try - { - var xml = await XDocument.LoadAsync(datStream, LoadOptions.None, Config.Cts.Token).ConfigureAwait(false); - if (xml.Root is null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to read DAT file as XML", true).ConfigureAwait(false); - return; - } - - await using var db = new BotDb(); - foreach (var element in xml.Root.Elements("game")) - { - var name = element.Element("rom")?.Attribute("name")?.Value; - if (string.IsNullOrEmpty(name)) - continue; - - // only match for "complex" names with several regions, or region-languages, or explicit revision - if (!Regex.IsMatch(name, @" (\(.+\)\s*\(.+\)|\(\w+(,\s*\w+)+\))\.iso$")) - continue; - - name = name[..^4]; //-.iso - if (await db.SuspiciousString.AnyAsync(ss => ss.String == name).ConfigureAwait(false)) - continue; - - db.SuspiciousString.Add(new() {String = name}); - count++; - } - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e, $"Failed to load DAT file {attachment.FileName}"); - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to read DAT file: " + e.Message, true).ConfigureAwait(false); - return; - } - - await ctx.ReactWithAsync(Config.Reactions.Success, $"Successfully imported {count} item{(count == 1 ? "" : "s")}", true).ConfigureAwait(false); - ContentFilter.RebuildMatcher(); - } - finally - { - ImportLock.Release(); - } - } - - [Command("edit"), Aliases("fix", "update", "change")] - [Description("Modifies the specified content filter")] - public async Task Edit(CommandContext ctx, [Description("Filter ID")] int id) - { - await using var db = new BotDb(); - var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.Id == id && !ps.Disabled).ConfigureAwait(false); - if (filter is null) - { - await ctx.Channel.SendMessageAsync("Specified filter does not exist").ConfigureAwait(false); - return; - } - - await EditFilterCmd(ctx, db, filter).ConfigureAwait(false); - } - - [Command("edit")] - public async Task Edit(CommandContext ctx, [Description("Trigger to edit"), RemainingText] string trigger) - { - await using var db = new BotDb(); - var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String == trigger && !ps.Disabled).ConfigureAwait(false); - if (filter is null) - { - await ctx.Channel.SendMessageAsync("Specified filter does not exist").ConfigureAwait(false); - return; - } - - await EditFilterCmd(ctx, db, filter).ConfigureAwait(false); - } - - [Command("view"), Aliases("show")] - [Description("Shows the details of the specified content filter")] - public async Task View(CommandContext ctx, [Description("Filter ID")] int id) - { - await using var db = new BotDb(); - var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.Id == id && !ps.Disabled).ConfigureAwait(false); - if (filter is null) - { - await ctx.Channel.SendMessageAsync("Specified filter does not exist").ConfigureAwait(false); - return; - } - - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(FormatFilter(filter))).ConfigureAwait(false); - } - - [Command("view")] - [Description("Shows the details of the specified content filter")] - public async Task View(CommandContext ctx, [Description("Trigger to view"), RemainingText] string trigger) - { - await using var db = new BotDb(); - var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String == trigger && !ps.Disabled).ConfigureAwait(false); - if (filter is null) - { - await ctx.Channel.SendMessageAsync("Specified filter does not exist").ConfigureAwait(false); - return; - } - - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(FormatFilter(filter))).ConfigureAwait(false); - } - - [Command("remove"), Aliases("delete", "del")] - [Description("Removes a content filter trigger")] - public async Task Remove(CommandContext ctx, [Description("Filter IDs to remove, separated with spaces")] params int[] ids) - { - int removedFilters; - var removedTriggers = new StringBuilder(); - await using (var db = new BotDb()) - { - foreach (var f in db.Piracystring.Where(ps => ids.Contains(ps.Id) && !ps.Disabled)) - { - f.Disabled = true; - removedTriggers.Append($"\n`{f.String.Sanitize()}`"); - } - removedFilters = await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - } - - if (removedFilters < ids.Length) - await ctx.Channel.SendMessageAsync("Some ids couldn't be removed.").ConfigureAwait(false); - else - { - await ctx.ReactWithAsync(Config.Reactions.Success, $"Trigger{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); - var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); - var s = removedFilters == 1 ? "" : "s"; - var filterList = removedTriggers.ToString(); - if (removedFilters == 1) - filterList = filterList.TrimStart(); - await ctx.Client.ReportAsync($"📴 Content filter{s} removed", $"{member?.GetMentionWithNickname()} removed {removedFilters} content filter{s}: {filterList}".Trim(EmbedPager.MaxDescriptionLength), null, ReportSeverity.Medium).ConfigureAwait(false); - } - ContentFilter.RebuildMatcher(); - } - - [Command("remove")] - public async Task Remove(CommandContext ctx, [Description("Trigger to remove"), RemainingText] string trigger) - { - if (string.IsNullOrWhiteSpace(trigger)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "No trigger was specified").ConfigureAwait(false); - return; - } - - await using (var db = new BotDb()) - { - var f = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String.Equals(trigger, StringComparison.InvariantCultureIgnoreCase) && !ps.Disabled).ConfigureAwait(false); - if (f is null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Specified filter does not exist").ConfigureAwait(false); - return; - } - - f.Disabled = true; - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - } - - await ctx.ReactWithAsync(Config.Reactions.Success, "Trigger was removed").ConfigureAwait(false); + await db.Piracystring.AddAsync(filter).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatFilter(filter).WithTitle("Created a new content filter #" + filter.Id)).ConfigureAwait(false); var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); - await ctx.Client.ReportAsync("📴 Content filter removed", $"{member?.GetMentionWithNickname()} removed 1 content filter: `{trigger.Sanitize()}`", null, ReportSeverity.Medium).ConfigureAwait(false); + var reportMsg = $"{member?.GetMentionWithNickname()} added a new content filter: `{filter.String.Sanitize()}`"; + if (!string.IsNullOrEmpty(filter.ValidatingRegex)) + reportMsg += $"\nValidation: `{filter.ValidatingRegex}`"; + await ctx.Client.ReportAsync("🆕 Content filter created", reportMsg, null, ReportSeverity.Low).ConfigureAwait(false); ContentFilter.RebuildMatcher(); } + else + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Content filter creation aborted").ConfigureAwait(false); + } - private static async Task EditFilterCmd(CommandContext ctx, BotDb db, Piracystring filter) + [Command("import"), RequiresBotSudoerRole] + [Description("Import suspicious strings for a certain dump collection from attached dat file (zip is fine)")] + public async Task Import(CommandContext ctx) + { + if (ctx.Message.Attachments.Count == 0) { - var (success, msg) = await EditFilterPropertiesAsync(ctx, db, filter).ConfigureAwait(false); - if (success) - { - await db.SaveChangesAsync().ConfigureAwait(false); - await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatFilter(filter).WithTitle("Updated content filter")).ConfigureAwait(false); - var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); - var reportMsg = $"{member?.GetMentionWithNickname()} changed content filter #{filter.Id} (`{filter.Actions.ToFlagsString()}`): `{filter.String.Sanitize()}`"; - if (!string.IsNullOrEmpty(filter.ValidatingRegex)) - reportMsg += $"\nValidation: `{filter.ValidatingRegex}`"; - await ctx.Client.ReportAsync("🆙 Content filter updated", reportMsg, null, ReportSeverity.Low).ConfigureAwait(false); - ContentFilter.RebuildMatcher(); - } - else - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Content filter update aborted").ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Failure, "No attached DAT file", true).ConfigureAwait(false); + return; } - private static async Task<(bool success, DiscordMessage? message)> EditFilterPropertiesAsync(CommandContext ctx, BotDb db, Piracystring filter) + if (!await ImportLock.WaitAsync(0)) { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Another import is in progress", true).ConfigureAwait(false); + return; + } + var count = 0; + try + { + var attachment = ctx.Message.Attachments[0]; + await using var datStream = Config.MemoryStreamManager.GetStream(); + using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); + await using var attachmentStream = await httpClient.GetStreamAsync(attachment.Url, Config.Cts.Token).ConfigureAwait(false); + if (attachment.FileName.ToLower().EndsWith(".dat")) + await attachmentStream.CopyToAsync(datStream, Config.Cts.Token).ConfigureAwait(false); + else if (attachment.FileName.ToLower().EndsWith(".zip")) + { + using var zipStream = new ZipArchive(attachmentStream, ZipArchiveMode.Read); + var entry = zipStream.Entries.FirstOrDefault(e => e.Name.ToLower().EndsWith(".dat")); + if (entry is null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Attached ZIP file doesn't contain DAT file", true).ConfigureAwait(false); + return; + } + + await using var entryStream = entry.Open(); + await entryStream.CopyToAsync(datStream, Config.Cts.Token).ConfigureAwait(false); + } + else + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Attached file is not recognized", true).ConfigureAwait(false); + return; + } + + datStream.Seek(0, SeekOrigin.Begin); try { - return await EditFilterPropertiesInternalAsync(ctx, db, filter).ConfigureAwait(false); + var xml = await XDocument.LoadAsync(datStream, LoadOptions.None, Config.Cts.Token).ConfigureAwait(false); + if (xml.Root is null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to read DAT file as XML", true).ConfigureAwait(false); + return; + } + + await using var db = new BotDb(); + foreach (var element in xml.Root.Elements("game")) + { + var name = element.Element("rom")?.Attribute("name")?.Value; + if (string.IsNullOrEmpty(name)) + continue; + + // only match for "complex" names with several regions, or region-languages, or explicit revision + if (!Regex.IsMatch(name, @" (\(.+\)\s*\(.+\)|\(\w+(,\s*\w+)+\))\.iso$")) + continue; + + name = name[..^4]; //-.iso + if (await db.SuspiciousString.AnyAsync(ss => ss.String == name).ConfigureAwait(false)) + continue; + + db.SuspiciousString.Add(new() {String = name}); + count++; + } + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); } catch (Exception e) { - Config.Log.Error(e, "Failed to edit content filter"); - return (false, null); + Config.Log.Error(e, $"Failed to load DAT file {attachment.FileName}"); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to read DAT file: " + e.Message, true).ConfigureAwait(false); + return; } + + await ctx.ReactWithAsync(Config.Reactions.Success, $"Successfully imported {count} item{(count == 1 ? "" : "s")}", true).ConfigureAwait(false); + ContentFilter.RebuildMatcher(); } - private static async Task<(bool success, DiscordMessage? message)> EditFilterPropertiesInternalAsync(CommandContext ctx, BotDb db, Piracystring filter) + finally { - var interact = ctx.Client.GetInteractivity(); - var abort = new DiscordButtonComponent(ButtonStyle.Danger, "filter:edit:abort", "Cancel", emoji: new(DiscordEmoji.FromUnicode("✖"))); - var lastPage = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:last", "To Last Field", emoji: new(DiscordEmoji.FromUnicode("⏭"))); - var firstPage = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:first", "To First Field", emoji: new(DiscordEmoji.FromUnicode("⏮"))); - var previousPage = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:previous", "Previous", emoji: new(DiscordEmoji.FromUnicode("◀"))); - var nextPage = new DiscordButtonComponent(ButtonStyle.Primary, "filter:edit:next", "Next", emoji: new(DiscordEmoji.FromUnicode("▶"))); - var trash = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:trash", "Clear", emoji: new(DiscordEmoji.FromUnicode("🗑"))); - var saveEdit = new DiscordButtonComponent(ButtonStyle.Success, "filter:edit:save", "Save", emoji: new(DiscordEmoji.FromUnicode("💾"))); - - var contextChat = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:context:chat", "Chat"); - var contextLog = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:context:log", "Log"); - var actionR = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:r", "R"); - var actionW = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:w", "W"); - var actionM = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:m", "M"); - var actionE = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:e", "E"); - var actionU = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:u", "U"); - var actionK = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:k", "K"); - - var minus = new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➖")); - var plus = new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕")); - - DiscordMessage? msg = null; - string? errorMsg = null; - DiscordMessage? txt; - ComponentInteractionCreateEventArgs? btn; - - step1: - // step 1: define trigger string - var embed = FormatFilter(filter, errorMsg, 1) - .WithDescription( - "Any simple string that is used to flag potential content for a check using Validation regex.\n" + - "**Must** be sufficiently long to reduce the number of checks." - ); - saveEdit.SetEnabled(filter.IsComplete()); - var messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify a new **trigger**") - .WithEmbed(embed) - .AddComponents(lastPage, nextPage, saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == lastPage.CustomId) - { - if (filter.Actions.HasFlag(FilterAction.ShowExplain)) - goto step6; - - if (filter.Actions.HasFlag(FilterAction.SendMessage)) - goto step5; - - goto step4; - } - } - else if (txt?.Content != null) - { - if (txt.Content.Length < Config.MinimumPiracyTriggerLength) - { - errorMsg = "Trigger is too short"; - goto step1; - } - - filter.String = txt.Content; - } - else - return (false, msg); - - step2: - // step 2: context of the filter where it is applicable - embed = FormatFilter(filter, errorMsg, 2) - .WithDescription( - "Context of the filter indicates where it is applicable.\n" + - $"**`C`** = **`{FilterContext.Chat}`** will apply it in filtering discord messages.\n" + - $"**`L`** = **`{FilterContext.Log}`** will apply it during log parsing.\n" + - "Reactions will toggle the context, text message will set the specified flags." - ); - saveEdit.SetEnabled(filter.IsComplete()); - contextChat.SetEmoji(filter.Context.HasFlag(FilterContext.Chat) ? minus : plus); - contextLog.SetEmoji(filter.Context.HasFlag(FilterContext.Log) ? minus : plus); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify filter **context(s)**") - .WithEmbed(embed) - .AddComponents(previousPage, nextPage, saveEdit, abort) - .AddComponents(contextChat, contextLog); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - goto step1; - - if (btn.Id == contextChat.CustomId) - { - filter.Context ^= FilterContext.Chat; - goto step2; - } - - if (btn.Id == contextLog.CustomId) - { - filter.Context ^= FilterContext.Log; - goto step2; - } - } - else if (txt != null) - { - var flagsTxt = txt.Content.Split(Separators, StringSplitOptions.RemoveEmptyEntries); - FilterContext newCtx = 0; - foreach (var f in flagsTxt) - { - switch (f.ToUpperInvariant()) - { - case "C": - case "CHAT": - newCtx |= FilterContext.Chat; - break; - case "L": - case "LOG": - case "LOGS": - newCtx |= FilterContext.Log; - break; - case "ABORT": - return (false, msg); - case "-": - case "SKIP": - case "NEXT": - break; - default: - errorMsg = $"Unknown context `{f}`."; - goto step2; - } - } - filter.Context = newCtx; - } - else - return (false, msg); - - step3: - // step 3: actions that should be performed on match - embed = FormatFilter(filter, errorMsg, 3) - .WithDescription( - "Actions that will be executed on positive match.\n" + - $"**`R`** = **`{FilterAction.RemoveContent}`** will remove the message / log.\n" + - $"**`W`** = **`{FilterAction.IssueWarning}`** will issue a warning to the user.\n" + - $"**`M`** = **`{FilterAction.SendMessage}`** send _a_ message with an explanation of why it was removed.\n" + - $"**`E`** = **`{FilterAction.ShowExplain}`** show `explain` for the specified term (**not implemented**).\n" + - $"**`U`** = **`{FilterAction.MuteModQueue}`** mute mod queue reporting for this action.\n" + - $"**`K`** = **`{FilterAction.Kick}`** kick user from server.\n" + - "Buttons will toggle the action, text message will set the specified flags." - ); - actionR.SetEmoji(filter.Actions.HasFlag(FilterAction.RemoveContent) ? minus : plus); - actionW.SetEmoji(filter.Actions.HasFlag(FilterAction.IssueWarning) ? minus : plus); - actionM.SetEmoji(filter.Actions.HasFlag(FilterAction.SendMessage) ? minus : plus); - actionE.SetEmoji(filter.Actions.HasFlag(FilterAction.ShowExplain) ? minus : plus); - actionU.SetEmoji(filter.Actions.HasFlag(FilterAction.MuteModQueue) ? minus : plus); - actionK.SetEmoji(filter.Actions.HasFlag(FilterAction.Kick) ? minus : plus); - saveEdit.SetEnabled(filter.IsComplete()); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify filter **action(s)**") - .WithEmbed(embed) - .AddComponents(previousPage, nextPage, saveEdit, abort) - .AddComponents(actionR, actionW, actionM, actionE, actionU) - .AddComponents(actionK); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - goto step2; - - if (btn.Id == actionR.CustomId) - { - filter.Actions ^= FilterAction.RemoveContent; - goto step3; - } - - if (btn.Id == actionW.CustomId) - { - filter.Actions ^= FilterAction.IssueWarning; - goto step3; - } - - if (btn.Id == actionM.CustomId) - { - filter.Actions ^= FilterAction.SendMessage; - goto step3; - } - - if (btn.Id == actionE.CustomId) - { - filter.Actions ^= FilterAction.ShowExplain; - goto step3; - } - - if (btn.Id == actionU.CustomId) - { - filter.Actions ^= FilterAction.MuteModQueue; - goto step3; - } - - if (btn.Id == actionK.CustomId) - { - filter.Actions ^= FilterAction.Kick; - goto step3; - } - } - else if (txt != null) - { - var flagsTxt = txt.Content.ToUpperInvariant().Split(Separators, StringSplitOptions.RemoveEmptyEntries); - if (flagsTxt.Length == 1 - && flagsTxt[0].Length <= Enum.GetValues(typeof(FilterAction)).Length) - flagsTxt = flagsTxt[0].Select(c => c.ToString()).ToArray(); - FilterAction newActions = 0; - foreach (var f in flagsTxt) - { - switch (f) - { - case "R": - case "REMOVE": - case "REMOVEMESSAGE": - newActions |= FilterAction.RemoveContent; - break; - case "W": - case "WARN": - case "WARNING": - case "ISSUEWARNING": - newActions |= FilterAction.IssueWarning; - break; - case "M": - case "MSG": - case "MESSAGE": - case "SENDMESSAGE": - newActions |= FilterAction.SendMessage; - break; - case "E": - case "X": - case "EXPLAIN": - case "SHOWEXPLAIN": - case "SENDEXPLAIN": - newActions |= FilterAction.ShowExplain; - break; - case "U": - case "MMQ": - case "MUTE": - case "MUTEMODQUEUE": - newActions |= FilterAction.MuteModQueue; - break; - case "K": - case "KICK": - newActions |= FilterAction.Kick; - break; - case "ABORT": - return (false, msg); - case "-": - case "SKIP": - case "NEXT": - break; - default: - errorMsg = $"Unknown action `{f.ToLowerInvariant()}`."; - goto step2; - } - } - filter.Actions = newActions; - } - else - return (false, msg); - - step4: - // step 4: validation regex to filter out false positives of the plaintext triggers - embed = FormatFilter(filter, errorMsg, 4) - .WithDescription( - "Validation [regex](https://docs.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-language-quick-reference) to optionally perform more strict trigger check.\n" + - "**Please [test](https://regex101.com/) your regex**. Following flags are enabled: Multiline, IgnoreCase.\n" + - "Additional validation can help reduce false positives of a plaintext trigger match." - ); - var next = (filter.Actions & (FilterAction.SendMessage | FilterAction.ShowExplain)) == 0 ? firstPage : nextPage; - trash.SetDisabled(string.IsNullOrEmpty(filter.ValidatingRegex)); - saveEdit.SetEnabled(filter.IsComplete()); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify filter **validation regex**") - .WithEmbed(embed) - .AddComponents(previousPage, next, trash, saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - goto step3; - - if (btn.Id == firstPage.CustomId) - goto step1; - - if (btn.Id == trash.CustomId) - filter.ValidatingRegex = null; - } - else if (txt != null) - { - if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-" || txt.Content == ".*") - filter.ValidatingRegex = null; - else - { - try - { - _ = Regex.IsMatch("test", txt.Content, RegexOptions.Multiline | RegexOptions.IgnoreCase); - } - catch (Exception e) - { - errorMsg = "Invalid regex expression: " + e.Message; - goto step4; - } - - filter.ValidatingRegex = txt.Content; - } - } - else - return (false, msg); - - if (filter.Actions.HasFlag(FilterAction.SendMessage)) - goto step5; - else if (filter.Actions.HasFlag(FilterAction.ShowExplain)) - goto step6; - else - goto stepConfirm; - - step5: - // step 5: optional custom message for the user - embed = FormatFilter(filter, errorMsg, 5) - .WithDescription( - "Optional custom message sent to the user.\n" + - "If left empty, default piracy warning message will be used." - ); - next = (filter.Actions.HasFlag(FilterAction.ShowExplain) ? nextPage : firstPage); - saveEdit.SetEnabled(filter.IsComplete()); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify filter **validation regex**") - .WithEmbed(embed) - .AddComponents(previousPage, next, saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - goto step4; - - if (btn.Id == firstPage.CustomId) - goto step1; - } - else if (txt != null) - { - if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-") - filter.CustomMessage = null; - else - filter.CustomMessage = txt.Content; - } - else - return (false, msg); - - if (filter.Actions.HasFlag(FilterAction.ShowExplain)) - goto step6; - else - goto stepConfirm; - - step6: - // step 6: show explanation for the term - embed = FormatFilter(filter, errorMsg, 6) - .WithDescription( - "Explanation term that is used to show an explanation.\n" + - "**__Currently not implemented__**." - ); - saveEdit.SetEnabled(filter.IsComplete()); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify filter **explanation term**") - .WithEmbed(embed) - .AddComponents(previousPage, firstPage, saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - { - if (filter.Actions.HasFlag(FilterAction.SendMessage)) - goto step5; - else - goto step4; - } - - if (btn.Id == firstPage.CustomId) - goto step1; - } - else if (txt != null) - { - if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-") - filter.ExplainTerm = null; - else - { - var term = txt.Content.ToLowerInvariant(); - var existingTerm = await db.Explanation.FirstOrDefaultAsync(exp => exp.Keyword == term).ConfigureAwait(false); - if (existingTerm == null) - { - errorMsg = $"Term `{txt.Content.ToLowerInvariant().Sanitize()}` is not defined."; - goto step6; - } - - filter.ExplainTerm = txt.Content; - } - } - else - return (false, msg); - - stepConfirm: - // last step: confirm - if (errorMsg == null && !filter.IsComplete()) - errorMsg = "Some required properties are not defined"; - saveEdit.SetEnabled(filter.IsComplete()); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Does this look good? (y/n)") - .WithEmbed(FormatFilter(filter, errorMsg)) - .AddComponents(previousPage, firstPage, saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - { - if (filter.Actions.HasFlag(FilterAction.ShowExplain)) - goto step6; - - if (filter.Actions.HasFlag(FilterAction.SendMessage)) - goto step5; - - goto step4; - } - - if (btn.Id == firstPage.CustomId) - goto step1; - } - else if (!string.IsNullOrEmpty(txt?.Content)) - { - if (!filter.IsComplete()) - goto step5; - - switch (txt.Content.ToLowerInvariant()) - { - case "yes": - case "y": - case "✅": - case "☑": - case "✔": - case "👌": - case "👍": - return (true, msg); - case "no": - case "n": - case "❎": - case "❌": - case "👎": - return (false, msg); - default: - errorMsg = "I don't know what you mean, so I'll just abort"; - if (filter.Actions.HasFlag(FilterAction.ShowExplain)) - goto step6; - - if (filter.Actions.HasFlag(FilterAction.SendMessage)) - goto step5; - - goto step4; - } - } - else - { - return (false, msg); - } - return (false, msg); - } - - private static DiscordEmbedBuilder FormatFilter(Piracystring filter, string? error = null, int highlight = -1) - { - var field = 1; - var result = new DiscordEmbedBuilder - { - Title = "Filter preview", - Color = string.IsNullOrEmpty(error) ? Config.Colors.Help : Config.Colors.Maintenance, - }; - if (!string.IsNullOrEmpty(error)) - result.AddField("Entry error", error); - - var validTrigger = string.IsNullOrEmpty(filter.String) || filter.String.Length < Config.MinimumPiracyTriggerLength ? "⚠ " : ""; - result.AddFieldEx(validTrigger + "Trigger", filter.String, highlight == field++, true) - .AddFieldEx("Context", filter.Context.ToString(), highlight == field++, true) - .AddFieldEx("Actions", filter.Actions.ToFlagsString(), highlight == field++, true) - .AddFieldEx("Validation", filter.ValidatingRegex ?? "", highlight == field++, true); - if (filter.Actions.HasFlag(FilterAction.SendMessage)) - result.AddFieldEx("Message", filter.CustomMessage ?? "", highlight == field, true); - field++; - if (filter.Actions.HasFlag(FilterAction.ShowExplain)) - { - var validExplainTerm = string.IsNullOrEmpty(filter.ExplainTerm) ? "⚠ " : ""; - result.AddFieldEx(validExplainTerm + "Explain", filter.ExplainTerm ?? "", highlight == field, true); - } -#if DEBUG - result.WithFooter("Test bot instance"); -#endif - return result; + ImportLock.Release(); } } -} + + [Command("edit"), Aliases("fix", "update", "change")] + [Description("Modifies the specified content filter")] + public async Task Edit(CommandContext ctx, [Description("Filter ID")] int id) + { + await using var db = new BotDb(); + var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.Id == id && !ps.Disabled).ConfigureAwait(false); + if (filter is null) + { + await ctx.Channel.SendMessageAsync("Specified filter does not exist").ConfigureAwait(false); + return; + } + + await EditFilterCmd(ctx, db, filter).ConfigureAwait(false); + } + + [Command("edit")] + public async Task Edit(CommandContext ctx, [Description("Trigger to edit"), RemainingText] string trigger) + { + await using var db = new BotDb(); + var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String == trigger && !ps.Disabled).ConfigureAwait(false); + if (filter is null) + { + await ctx.Channel.SendMessageAsync("Specified filter does not exist").ConfigureAwait(false); + return; + } + + await EditFilterCmd(ctx, db, filter).ConfigureAwait(false); + } + + [Command("view"), Aliases("show")] + [Description("Shows the details of the specified content filter")] + public async Task View(CommandContext ctx, [Description("Filter ID")] int id) + { + await using var db = new BotDb(); + var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.Id == id && !ps.Disabled).ConfigureAwait(false); + if (filter is null) + { + await ctx.Channel.SendMessageAsync("Specified filter does not exist").ConfigureAwait(false); + return; + } + + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(FormatFilter(filter))).ConfigureAwait(false); + } + + [Command("view")] + [Description("Shows the details of the specified content filter")] + public async Task View(CommandContext ctx, [Description("Trigger to view"), RemainingText] string trigger) + { + await using var db = new BotDb(); + var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String == trigger && !ps.Disabled).ConfigureAwait(false); + if (filter is null) + { + await ctx.Channel.SendMessageAsync("Specified filter does not exist").ConfigureAwait(false); + return; + } + + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(FormatFilter(filter))).ConfigureAwait(false); + } + + [Command("remove"), Aliases("delete", "del")] + [Description("Removes a content filter trigger")] + public async Task Remove(CommandContext ctx, [Description("Filter IDs to remove, separated with spaces")] params int[] ids) + { + int removedFilters; + var removedTriggers = new StringBuilder(); + await using (var db = new BotDb()) + { + foreach (var f in db.Piracystring.Where(ps => ids.Contains(ps.Id) && !ps.Disabled)) + { + f.Disabled = true; + removedTriggers.Append($"\n`{f.String.Sanitize()}`"); + } + removedFilters = await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + } + + if (removedFilters < ids.Length) + await ctx.Channel.SendMessageAsync("Some ids couldn't be removed.").ConfigureAwait(false); + else + { + await ctx.ReactWithAsync(Config.Reactions.Success, $"Trigger{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); + var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); + var s = removedFilters == 1 ? "" : "s"; + var filterList = removedTriggers.ToString(); + if (removedFilters == 1) + filterList = filterList.TrimStart(); + await ctx.Client.ReportAsync($"📴 Content filter{s} removed", $"{member?.GetMentionWithNickname()} removed {removedFilters} content filter{s}: {filterList}".Trim(EmbedPager.MaxDescriptionLength), null, ReportSeverity.Medium).ConfigureAwait(false); + } + ContentFilter.RebuildMatcher(); + } + + [Command("remove")] + public async Task Remove(CommandContext ctx, [Description("Trigger to remove"), RemainingText] string trigger) + { + if (string.IsNullOrWhiteSpace(trigger)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "No trigger was specified").ConfigureAwait(false); + return; + } + + await using (var db = new BotDb()) + { + var f = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String.Equals(trigger, StringComparison.InvariantCultureIgnoreCase) && !ps.Disabled).ConfigureAwait(false); + if (f is null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Specified filter does not exist").ConfigureAwait(false); + return; + } + + f.Disabled = true; + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + } + + await ctx.ReactWithAsync(Config.Reactions.Success, "Trigger was removed").ConfigureAwait(false); + var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); + await ctx.Client.ReportAsync("📴 Content filter removed", $"{member?.GetMentionWithNickname()} removed 1 content filter: `{trigger.Sanitize()}`", null, ReportSeverity.Medium).ConfigureAwait(false); + ContentFilter.RebuildMatcher(); + } + + private static async Task EditFilterCmd(CommandContext ctx, BotDb db, Piracystring filter) + { + var (success, msg) = await EditFilterPropertiesAsync(ctx, db, filter).ConfigureAwait(false); + if (success) + { + await db.SaveChangesAsync().ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatFilter(filter).WithTitle("Updated content filter")).ConfigureAwait(false); + var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); + var reportMsg = $"{member?.GetMentionWithNickname()} changed content filter #{filter.Id} (`{filter.Actions.ToFlagsString()}`): `{filter.String.Sanitize()}`"; + if (!string.IsNullOrEmpty(filter.ValidatingRegex)) + reportMsg += $"\nValidation: `{filter.ValidatingRegex}`"; + await ctx.Client.ReportAsync("🆙 Content filter updated", reportMsg, null, ReportSeverity.Low).ConfigureAwait(false); + ContentFilter.RebuildMatcher(); + } + else + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Content filter update aborted").ConfigureAwait(false); + } + + private static async Task<(bool success, DiscordMessage? message)> EditFilterPropertiesAsync(CommandContext ctx, BotDb db, Piracystring filter) + { + try + { + return await EditFilterPropertiesInternalAsync(ctx, db, filter).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e, "Failed to edit content filter"); + return (false, null); + } + } + private static async Task<(bool success, DiscordMessage? message)> EditFilterPropertiesInternalAsync(CommandContext ctx, BotDb db, Piracystring filter) + { + var interact = ctx.Client.GetInteractivity(); + var abort = new DiscordButtonComponent(ButtonStyle.Danger, "filter:edit:abort", "Cancel", emoji: new(DiscordEmoji.FromUnicode("✖"))); + var lastPage = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:last", "To Last Field", emoji: new(DiscordEmoji.FromUnicode("⏭"))); + var firstPage = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:first", "To First Field", emoji: new(DiscordEmoji.FromUnicode("⏮"))); + var previousPage = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:previous", "Previous", emoji: new(DiscordEmoji.FromUnicode("◀"))); + var nextPage = new DiscordButtonComponent(ButtonStyle.Primary, "filter:edit:next", "Next", emoji: new(DiscordEmoji.FromUnicode("▶"))); + var trash = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:trash", "Clear", emoji: new(DiscordEmoji.FromUnicode("🗑"))); + var saveEdit = new DiscordButtonComponent(ButtonStyle.Success, "filter:edit:save", "Save", emoji: new(DiscordEmoji.FromUnicode("💾"))); + + var contextChat = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:context:chat", "Chat"); + var contextLog = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:context:log", "Log"); + var actionR = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:r", "R"); + var actionW = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:w", "W"); + var actionM = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:m", "M"); + var actionE = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:e", "E"); + var actionU = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:u", "U"); + var actionK = new DiscordButtonComponent(ButtonStyle.Secondary, "filter:edit:action:k", "K"); + + var minus = new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➖")); + var plus = new DiscordComponentEmoji(DiscordEmoji.FromUnicode("➕")); + + DiscordMessage? msg = null; + string? errorMsg = null; + DiscordMessage? txt; + ComponentInteractionCreateEventArgs? btn; + + step1: + // step 1: define trigger string + var embed = FormatFilter(filter, errorMsg, 1) + .WithDescription( + "Any simple string that is used to flag potential content for a check using Validation regex.\n" + + "**Must** be sufficiently long to reduce the number of checks." + ); + saveEdit.SetEnabled(filter.IsComplete()); + var messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify a new **trigger**") + .WithEmbed(embed) + .AddComponents(lastPage, nextPage, saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) + return (false, msg); + + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == lastPage.CustomId) + { + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + + goto step4; + } + } + else if (txt?.Content != null) + { + if (txt.Content.Length < Config.MinimumPiracyTriggerLength) + { + errorMsg = "Trigger is too short"; + goto step1; + } + + filter.String = txt.Content; + } + else + return (false, msg); + + step2: + // step 2: context of the filter where it is applicable + embed = FormatFilter(filter, errorMsg, 2) + .WithDescription( + "Context of the filter indicates where it is applicable.\n" + + $"**`C`** = **`{FilterContext.Chat}`** will apply it in filtering discord messages.\n" + + $"**`L`** = **`{FilterContext.Log}`** will apply it during log parsing.\n" + + "Reactions will toggle the context, text message will set the specified flags." + ); + saveEdit.SetEnabled(filter.IsComplete()); + contextChat.SetEmoji(filter.Context.HasFlag(FilterContext.Chat) ? minus : plus); + contextLog.SetEmoji(filter.Context.HasFlag(FilterContext.Log) ? minus : plus); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify filter **context(s)**") + .WithEmbed(embed) + .AddComponents(previousPage, nextPage, saveEdit, abort) + .AddComponents(contextChat, contextLog); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) + return (false, msg); + + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + goto step1; + + if (btn.Id == contextChat.CustomId) + { + filter.Context ^= FilterContext.Chat; + goto step2; + } + + if (btn.Id == contextLog.CustomId) + { + filter.Context ^= FilterContext.Log; + goto step2; + } + } + else if (txt != null) + { + var flagsTxt = txt.Content.Split(Separators, StringSplitOptions.RemoveEmptyEntries); + FilterContext newCtx = 0; + foreach (var f in flagsTxt) + { + switch (f.ToUpperInvariant()) + { + case "C": + case "CHAT": + newCtx |= FilterContext.Chat; + break; + case "L": + case "LOG": + case "LOGS": + newCtx |= FilterContext.Log; + break; + case "ABORT": + return (false, msg); + case "-": + case "SKIP": + case "NEXT": + break; + default: + errorMsg = $"Unknown context `{f}`."; + goto step2; + } + } + filter.Context = newCtx; + } + else + return (false, msg); + + step3: + // step 3: actions that should be performed on match + embed = FormatFilter(filter, errorMsg, 3) + .WithDescription( + "Actions that will be executed on positive match.\n" + + $"**`R`** = **`{FilterAction.RemoveContent}`** will remove the message / log.\n" + + $"**`W`** = **`{FilterAction.IssueWarning}`** will issue a warning to the user.\n" + + $"**`M`** = **`{FilterAction.SendMessage}`** send _a_ message with an explanation of why it was removed.\n" + + $"**`E`** = **`{FilterAction.ShowExplain}`** show `explain` for the specified term (**not implemented**).\n" + + $"**`U`** = **`{FilterAction.MuteModQueue}`** mute mod queue reporting for this action.\n" + + $"**`K`** = **`{FilterAction.Kick}`** kick user from server.\n" + + "Buttons will toggle the action, text message will set the specified flags." + ); + actionR.SetEmoji(filter.Actions.HasFlag(FilterAction.RemoveContent) ? minus : plus); + actionW.SetEmoji(filter.Actions.HasFlag(FilterAction.IssueWarning) ? minus : plus); + actionM.SetEmoji(filter.Actions.HasFlag(FilterAction.SendMessage) ? minus : plus); + actionE.SetEmoji(filter.Actions.HasFlag(FilterAction.ShowExplain) ? minus : plus); + actionU.SetEmoji(filter.Actions.HasFlag(FilterAction.MuteModQueue) ? minus : plus); + actionK.SetEmoji(filter.Actions.HasFlag(FilterAction.Kick) ? minus : plus); + saveEdit.SetEnabled(filter.IsComplete()); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify filter **action(s)**") + .WithEmbed(embed) + .AddComponents(previousPage, nextPage, saveEdit, abort) + .AddComponents(actionR, actionW, actionM, actionE, actionU) + .AddComponents(actionK); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) + return (false, msg); + + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + goto step2; + + if (btn.Id == actionR.CustomId) + { + filter.Actions ^= FilterAction.RemoveContent; + goto step3; + } + + if (btn.Id == actionW.CustomId) + { + filter.Actions ^= FilterAction.IssueWarning; + goto step3; + } + + if (btn.Id == actionM.CustomId) + { + filter.Actions ^= FilterAction.SendMessage; + goto step3; + } + + if (btn.Id == actionE.CustomId) + { + filter.Actions ^= FilterAction.ShowExplain; + goto step3; + } + + if (btn.Id == actionU.CustomId) + { + filter.Actions ^= FilterAction.MuteModQueue; + goto step3; + } + + if (btn.Id == actionK.CustomId) + { + filter.Actions ^= FilterAction.Kick; + goto step3; + } + } + else if (txt != null) + { + var flagsTxt = txt.Content.ToUpperInvariant().Split(Separators, StringSplitOptions.RemoveEmptyEntries); + if (flagsTxt.Length == 1 + && flagsTxt[0].Length <= Enum.GetValues(typeof(FilterAction)).Length) + flagsTxt = flagsTxt[0].Select(c => c.ToString()).ToArray(); + FilterAction newActions = 0; + foreach (var f in flagsTxt) + { + switch (f) + { + case "R": + case "REMOVE": + case "REMOVEMESSAGE": + newActions |= FilterAction.RemoveContent; + break; + case "W": + case "WARN": + case "WARNING": + case "ISSUEWARNING": + newActions |= FilterAction.IssueWarning; + break; + case "M": + case "MSG": + case "MESSAGE": + case "SENDMESSAGE": + newActions |= FilterAction.SendMessage; + break; + case "E": + case "X": + case "EXPLAIN": + case "SHOWEXPLAIN": + case "SENDEXPLAIN": + newActions |= FilterAction.ShowExplain; + break; + case "U": + case "MMQ": + case "MUTE": + case "MUTEMODQUEUE": + newActions |= FilterAction.MuteModQueue; + break; + case "K": + case "KICK": + newActions |= FilterAction.Kick; + break; + case "ABORT": + return (false, msg); + case "-": + case "SKIP": + case "NEXT": + break; + default: + errorMsg = $"Unknown action `{f.ToLowerInvariant()}`."; + goto step2; + } + } + filter.Actions = newActions; + } + else + return (false, msg); + + step4: + // step 4: validation regex to filter out false positives of the plaintext triggers + embed = FormatFilter(filter, errorMsg, 4) + .WithDescription( + "Validation [regex](https://docs.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-language-quick-reference) to optionally perform more strict trigger check.\n" + + "**Please [test](https://regex101.com/) your regex**. Following flags are enabled: Multiline, IgnoreCase.\n" + + "Additional validation can help reduce false positives of a plaintext trigger match." + ); + var next = (filter.Actions & (FilterAction.SendMessage | FilterAction.ShowExplain)) == 0 ? firstPage : nextPage; + trash.SetDisabled(string.IsNullOrEmpty(filter.ValidatingRegex)); + saveEdit.SetEnabled(filter.IsComplete()); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify filter **validation regex**") + .WithEmbed(embed) + .AddComponents(previousPage, next, trash, saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) + return (false, msg); + + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + goto step3; + + if (btn.Id == firstPage.CustomId) + goto step1; + + if (btn.Id == trash.CustomId) + filter.ValidatingRegex = null; + } + else if (txt != null) + { + if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-" || txt.Content == ".*") + filter.ValidatingRegex = null; + else + { + try + { + _ = Regex.IsMatch("test", txt.Content, RegexOptions.Multiline | RegexOptions.IgnoreCase); + } + catch (Exception e) + { + errorMsg = "Invalid regex expression: " + e.Message; + goto step4; + } + + filter.ValidatingRegex = txt.Content; + } + } + else + return (false, msg); + + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + else if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + else + goto stepConfirm; + + step5: + // step 5: optional custom message for the user + embed = FormatFilter(filter, errorMsg, 5) + .WithDescription( + "Optional custom message sent to the user.\n" + + "If left empty, default piracy warning message will be used." + ); + next = (filter.Actions.HasFlag(FilterAction.ShowExplain) ? nextPage : firstPage); + saveEdit.SetEnabled(filter.IsComplete()); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify filter **validation regex**") + .WithEmbed(embed) + .AddComponents(previousPage, next, saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) + return (false, msg); + + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + goto step4; + + if (btn.Id == firstPage.CustomId) + goto step1; + } + else if (txt != null) + { + if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-") + filter.CustomMessage = null; + else + filter.CustomMessage = txt.Content; + } + else + return (false, msg); + + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + else + goto stepConfirm; + + step6: + // step 6: show explanation for the term + embed = FormatFilter(filter, errorMsg, 6) + .WithDescription( + "Explanation term that is used to show an explanation.\n" + + "**__Currently not implemented__**." + ); + saveEdit.SetEnabled(filter.IsComplete()); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify filter **explanation term**") + .WithEmbed(embed) + .AddComponents(previousPage, firstPage, saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) + return (false, msg); + + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + { + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + else + goto step4; + } + + if (btn.Id == firstPage.CustomId) + goto step1; + } + else if (txt != null) + { + if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-") + filter.ExplainTerm = null; + else + { + var term = txt.Content.ToLowerInvariant(); + var existingTerm = await db.Explanation.FirstOrDefaultAsync(exp => exp.Keyword == term).ConfigureAwait(false); + if (existingTerm == null) + { + errorMsg = $"Term `{txt.Content.ToLowerInvariant().Sanitize()}` is not defined."; + goto step6; + } + + filter.ExplainTerm = txt.Content; + } + } + else + return (false, msg); + + stepConfirm: + // last step: confirm + if (errorMsg == null && !filter.IsComplete()) + errorMsg = "Some required properties are not defined"; + saveEdit.SetEnabled(filter.IsComplete()); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Does this look good? (y/n)") + .WithEmbed(FormatFilter(filter, errorMsg)) + .AddComponents(previousPage, firstPage, saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) + return (false, msg); + + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + { + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + + goto step4; + } + + if (btn.Id == firstPage.CustomId) + goto step1; + } + else if (!string.IsNullOrEmpty(txt?.Content)) + { + if (!filter.IsComplete()) + goto step5; + + switch (txt.Content.ToLowerInvariant()) + { + case "yes": + case "y": + case "✅": + case "☑": + case "✔": + case "👌": + case "👍": + return (true, msg); + case "no": + case "n": + case "❎": + case "❌": + case "👎": + return (false, msg); + default: + errorMsg = "I don't know what you mean, so I'll just abort"; + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + + goto step4; + } + } + else + { + return (false, msg); + } + return (false, msg); + } + + private static DiscordEmbedBuilder FormatFilter(Piracystring filter, string? error = null, int highlight = -1) + { + var field = 1; + var result = new DiscordEmbedBuilder + { + Title = "Filter preview", + Color = string.IsNullOrEmpty(error) ? Config.Colors.Help : Config.Colors.Maintenance, + }; + if (!string.IsNullOrEmpty(error)) + result.AddField("Entry error", error); + + var validTrigger = string.IsNullOrEmpty(filter.String) || filter.String.Length < Config.MinimumPiracyTriggerLength ? "⚠ " : ""; + result.AddFieldEx(validTrigger + "Trigger", filter.String, highlight == field++, true) + .AddFieldEx("Context", filter.Context.ToString(), highlight == field++, true) + .AddFieldEx("Actions", filter.Actions.ToFlagsString(), highlight == field++, true) + .AddFieldEx("Validation", filter.ValidatingRegex ?? "", highlight == field++, true); + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + result.AddFieldEx("Message", filter.CustomMessage ?? "", highlight == field, true); + field++; + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + { + var validExplainTerm = string.IsNullOrEmpty(filter.ExplainTerm) ? "⚠ " : ""; + result.AddFieldEx(validExplainTerm + "Explain", filter.ExplainTerm ?? "", highlight == field, true); + } +#if DEBUG + result.WithFooter("Test bot instance"); +#endif + return result; + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Converters/TextOnlyDiscordChannelConverter.cs b/CompatBot/Commands/Converters/TextOnlyDiscordChannelConverter.cs index 6e82facf..78b3f082 100644 --- a/CompatBot/Commands/Converters/TextOnlyDiscordChannelConverter.cs +++ b/CompatBot/Commands/Converters/TextOnlyDiscordChannelConverter.cs @@ -8,56 +8,55 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Converters; using DSharpPlus.Entities; -namespace CompatBot.Commands.Converters +namespace CompatBot.Commands.Converters; + +internal sealed class TextOnlyDiscordChannelConverter : IArgumentConverter { - internal sealed class TextOnlyDiscordChannelConverter : IArgumentConverter - { - private static Regex ChannelRegex { get; } = new(@"^<#(\d+)>$", RegexOptions.ECMAScript | RegexOptions.Compiled); + private static Regex ChannelRegex { get; } = new(@"^<#(\d+)>$", RegexOptions.ECMAScript | RegexOptions.Compiled); - Task> IArgumentConverter.ConvertAsync(string value, CommandContext ctx) - => ConvertAsync(value, ctx); + Task> IArgumentConverter.ConvertAsync(string value, CommandContext ctx) + => ConvertAsync(value, ctx); - public static async Task> ConvertAsync(string value, CommandContext ctx) + public static async Task> ConvertAsync(string value, CommandContext ctx) + { + var guildList = new List(ctx.Client.Guilds.Count); + if (ctx.Guild == null) + foreach (var g in ctx.Client.Guilds.Keys) + guildList.Add(await ctx.Client.GetGuildAsync(g).ConfigureAwait(false)); + else + guildList.Add(ctx.Guild); + + if (ulong.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cid)) { - var guildList = new List(ctx.Client.Guilds.Count); - if (ctx.Guild == null) - foreach (var g in ctx.Client.Guilds.Keys) - guildList.Add(await ctx.Client.GetGuildAsync(g).ConfigureAwait(false)); - else - guildList.Add(ctx.Guild); - - if (ulong.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cid)) - { - var result = ( - from g in guildList - from ch in g.Channels - select ch - ).FirstOrDefault(xc => xc.Key == cid && xc.Value?.Type == ChannelType.Text); - var ret = result.Value == null ? Optional.FromNoValue() : Optional.FromValue(result.Value); - return ret; - } - - var m = ChannelRegex.Match(value); - if (m.Success && ulong.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out cid)) - { - var result = ( - from g in guildList - from ch in g.Channels - select ch - ).FirstOrDefault(xc => xc.Key == cid && xc.Value?.Type == ChannelType.Text); - var ret = result.Value == null ? Optional.FromNoValue() : Optional.FromValue(result.Value); - return ret; - } - - if (value.StartsWith('#')) - value = value[1..]; - value = value.ToLowerInvariant(); - var chn = ( + var result = ( from g in guildList from ch in g.Channels select ch - ).FirstOrDefault(xc => xc.Value?.Name.ToLowerInvariant() == value && xc.Value?.Type == ChannelType.Text); - return chn.Value == null ? Optional.FromNoValue() : Optional.FromValue(chn.Value); + ).FirstOrDefault(xc => xc.Key == cid && xc.Value?.Type == ChannelType.Text); + var ret = result.Value == null ? Optional.FromNoValue() : Optional.FromValue(result.Value); + return ret; } + + var m = ChannelRegex.Match(value); + if (m.Success && ulong.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out cid)) + { + var result = ( + from g in guildList + from ch in g.Channels + select ch + ).FirstOrDefault(xc => xc.Key == cid && xc.Value?.Type == ChannelType.Text); + var ret = result.Value == null ? Optional.FromNoValue() : Optional.FromValue(result.Value); + return ret; + } + + if (value.StartsWith('#')) + value = value[1..]; + value = value.ToLowerInvariant(); + var chn = ( + from g in guildList + from ch in g.Channels + select ch + ).FirstOrDefault(xc => xc.Value?.Name.ToLowerInvariant() == value && xc.Value?.Type == ChannelType.Text); + return chn.Value == null ? Optional.FromNoValue() : Optional.FromValue(chn.Value); } } \ No newline at end of file diff --git a/CompatBot/Commands/DevOnly.cs b/CompatBot/Commands/DevOnly.cs index fd10cf3e..302f0f47 100644 --- a/CompatBot/Commands/DevOnly.cs +++ b/CompatBot/Commands/DevOnly.cs @@ -7,64 +7,63 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal sealed class DevOnly : BaseCommandModuleCustom { - internal sealed class DevOnly : BaseCommandModuleCustom + [Command("whitespacetest"), Aliases("wst", "wstest")] + [Description("Testing discord embeds breakage for whitespaces")] + public async Task WhitespaceTest(CommandContext ctx) { - [Command("whitespacetest"), Aliases("wst", "wstest")] - [Description("Testing discord embeds breakage for whitespaces")] - public async Task WhitespaceTest(CommandContext ctx) - { - var checkMark = "[\u00a0]"; - const int width = 20; - var result = new StringBuilder($"` 1. Dots:{checkMark.PadLeft(width, '.')}`").AppendLine() - .AppendLine($"` 2. None:{checkMark,width}`"); - var ln = 3; - foreach (var c in StringUtils.SpaceCharacters) - result.AppendLine($"`{ln++,2}. {(int)c:x4}:{checkMark,width}`"); + var checkMark = "[\u00a0]"; + const int width = 20; + var result = new StringBuilder($"` 1. Dots:{checkMark.PadLeft(width, '.')}`").AppendLine() + .AppendLine($"` 2. None:{checkMark,width}`"); + var ln = 3; + foreach (var c in StringUtils.SpaceCharacters) + result.AppendLine($"`{ln++,2}. {(int)c:x4}:{checkMark,width}`"); #pragma warning disable 8321 - static void addRandomStuff(DiscordEmbedBuilder emb) - { - var txt = "😾 lasjdf wqoieyr osdf `Vreoh Sdab` wohe `270`\n" + - "🤔 salfhiosfhsero hskfh shufwei oufhwehw e wkihrwe h\n" + - "ℹ sakfjas f hs `ASfhewighehw safds` asfw\n" + - "🔮 ¯\\\\\\_(ツ)\\_/¯"; - - emb.AddField("Random section", txt, false); - } -#pragma warning restore 8321 - var embed = new DiscordEmbedBuilder() - .WithTitle("Whitespace embed test") - .WithDescription("In a perfect world all these lines would look the same, with perfectly formed columns"); - - var lines = result.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - var embedList = lines.BreakInEmbeds(embed, lines.Length / 2 + lines.Length % 2, "Normal"); - foreach (var _ in embedList) - { - //drain the enumerable - } - embed.AddField("-", "-", false); - - lines = result.ToString().Replace(' ', StringUtils.Nbsp).Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - embedList = lines.BreakInEmbeds(embed, lines.Length / 2 + lines.Length % 2, "Non-breakable spaces"); - foreach (var _ in embedList) - { - //drain the enumerable - } - await ctx.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); - } - - [Command("buttons")] - [Description("Buttons test")] - public async Task Buttons(CommandContext ctx) + static void addRandomStuff(DiscordEmbedBuilder emb) { - var builder = new DiscordMessageBuilder() - .WithContent("Regular button vs emoji button") - .AddComponents( - new DiscordButtonComponent(ButtonStyle.Primary, "pt", "✅ Regular"), - new DiscordButtonComponent(ButtonStyle.Primary, "pe", "Emoji", emoji: new(DiscordEmoji.FromUnicode("✅"))) - ); - await ctx.RespondAsync(builder).ConfigureAwait(false); + var txt = "😾 lasjdf wqoieyr osdf `Vreoh Sdab` wohe `270`\n" + + "🤔 salfhiosfhsero hskfh shufwei oufhwehw e wkihrwe h\n" + + "ℹ sakfjas f hs `ASfhewighehw safds` asfw\n" + + "🔮 ¯\\\\\\_(ツ)\\_/¯"; + + emb.AddField("Random section", txt, false); } +#pragma warning restore 8321 + var embed = new DiscordEmbedBuilder() + .WithTitle("Whitespace embed test") + .WithDescription("In a perfect world all these lines would look the same, with perfectly formed columns"); + + var lines = result.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + var embedList = lines.BreakInEmbeds(embed, lines.Length / 2 + lines.Length % 2, "Normal"); + foreach (var _ in embedList) + { + //drain the enumerable + } + embed.AddField("-", "-", false); + + lines = result.ToString().Replace(' ', StringUtils.Nbsp).Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + embedList = lines.BreakInEmbeds(embed, lines.Length / 2 + lines.Length % 2, "Non-breakable spaces"); + foreach (var _ in embedList) + { + //drain the enumerable + } + await ctx.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + } + + [Command("buttons")] + [Description("Buttons test")] + public async Task Buttons(CommandContext ctx) + { + var builder = new DiscordMessageBuilder() + .WithContent("Regular button vs emoji button") + .AddComponents( + new DiscordButtonComponent(ButtonStyle.Primary, "pt", "✅ Regular"), + new DiscordButtonComponent(ButtonStyle.Primary, "pe", "Emoji", emoji: new(DiscordEmoji.FromUnicode("✅"))) + ); + await ctx.RespondAsync(builder).ConfigureAwait(false); } } \ No newline at end of file diff --git a/CompatBot/Commands/E3.cs b/CompatBot/Commands/E3.cs index 35131391..f3d9bfc0 100644 --- a/CompatBot/Commands/E3.cs +++ b/CompatBot/Commands/E3.cs @@ -3,45 +3,44 @@ using CompatBot.Commands.Attributes; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("e3")] +[Description("Provides information about the E3 event")] +internal sealed class E3: EventsBaseCommand { - [Group("e3")] - [Description("Provides information about the E3 event")] - internal sealed class E3: EventsBaseCommand - { - [GroupCommand] - public Task E3Countdown(CommandContext ctx) - => NearestEvent(ctx, "E3"); + [GroupCommand] + public Task E3Countdown(CommandContext ctx) + => NearestEvent(ctx, "E3"); - [Command("add"), RequiresBotModRole] - [Description("Adds new E3 event to the schedule")] - public Task AddE3(CommandContext ctx) - => Add(ctx, "E3"); + [Command("add"), RequiresBotModRole] + [Description("Adds new E3 event to the schedule")] + public Task AddE3(CommandContext ctx) + => Add(ctx, "E3"); - [Command("remove"), Aliases("delete", "del"), RequiresBotModRole] - [Description("Removes event with the specified IDs")] - public Task RemoveE3(CommandContext ctx, [Description("Event IDs to remove separated with space")] params int[] ids) - => Remove(ctx, ids); + [Command("remove"), Aliases("delete", "del"), RequiresBotModRole] + [Description("Removes event with the specified IDs")] + public Task RemoveE3(CommandContext ctx, [Description("Event IDs to remove separated with space")] params int[] ids) + => Remove(ctx, ids); - [Command("clean"), Aliases("cleanup", "Clear"), RequiresBotModRole] - [Description("Removes past events")] - public Task ClearE3(CommandContext ctx, [Description("Optional year to remove, by default everything before current year")] int? year = null) - => Clear(ctx, year); + [Command("clean"), Aliases("cleanup", "Clear"), RequiresBotModRole] + [Description("Removes past events")] + public Task ClearE3(CommandContext ctx, [Description("Optional year to remove, by default everything before current year")] int? year = null) + => Clear(ctx, year); - [Command("edit"), Aliases("adjust", "change", "modify", "update"), RequiresBotModRole] - [Description("Updates the event entry properties")] - public Task AdjustE3(CommandContext ctx, [Description("Event ID")] int id) - => Update(ctx, id, "E3"); + [Command("edit"), Aliases("adjust", "change", "modify", "update"), RequiresBotModRole] + [Description("Updates the event entry properties")] + public Task AdjustE3(CommandContext ctx, [Description("Event ID")] int id) + => Update(ctx, id, "E3"); - [Command("schedule"), Aliases("show", "list")] - [Description("Outputs current schedule")] - public Task ListE3(CommandContext ctx, [Description("Optional year to list")] int? year = null) - => List(ctx, "E3", year); + [Command("schedule"), Aliases("show", "list")] + [Description("Outputs current schedule")] + public Task ListE3(CommandContext ctx, [Description("Optional year to list")] int? year = null) + => List(ctx, "E3", year); - [Command("countdown")] - [Description("Provides countdown for the nearest known E3 event")] - public Task Countdown(CommandContext ctx) - => E3Countdown(ctx); - } -} + [Command("countdown")] + [Description("Provides countdown for the nearest known E3 event")] + public Task Countdown(CommandContext ctx) + => E3Countdown(ctx); +} \ No newline at end of file diff --git a/CompatBot/Commands/Events.cs b/CompatBot/Commands/Events.cs index 26d5b3ae..f96e9085 100644 --- a/CompatBot/Commands/Events.cs +++ b/CompatBot/Commands/Events.cs @@ -3,60 +3,59 @@ using CompatBot.Commands.Attributes; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("event"), Aliases("events", "e")] +[Description("Provides information about the various events in the game industry")] +internal sealed class Events: EventsBaseCommand { - [Group("event"), Aliases("events", "e")] - [Description("Provides information about the various events in the game industry")] - internal sealed class Events: EventsBaseCommand - { - [GroupCommand] - public Task NearestGenericEvent(CommandContext ctx, [Description("Optional event name"), RemainingText] string? eventName = null) - => NearestEvent(ctx, eventName); + [GroupCommand] + public Task NearestGenericEvent(CommandContext ctx, [Description("Optional event name"), RemainingText] string? eventName = null) + => NearestEvent(ctx, eventName); - [Command("add"), RequiresBotModRole] - [Description("Adds a new entry to the schedule")] - public Task AddGeneric(CommandContext ctx) - => Add(ctx); + [Command("add"), RequiresBotModRole] + [Description("Adds a new entry to the schedule")] + public Task AddGeneric(CommandContext ctx) + => Add(ctx); - [Command("remove"), Aliases("delete", "del"), RequiresBotModRole] - [Description("Removes schedule entries with the specified IDs")] - public Task RemoveGeneric(CommandContext ctx, [Description("Event IDs to remove separated with space")] params int[] ids) - => Remove(ctx, ids); + [Command("remove"), Aliases("delete", "del"), RequiresBotModRole] + [Description("Removes schedule entries with the specified IDs")] + public Task RemoveGeneric(CommandContext ctx, [Description("Event IDs to remove separated with space")] params int[] ids) + => Remove(ctx, ids); - [Command("clean"), Aliases("cleanup", "Clear"), RequiresBotModRole] - [Description("Removes past events")] - public Task ClearGeneric(CommandContext ctx, [Description("Optional year to remove, by default everything before current year")] int? year = null) - => Clear(ctx, year); + [Command("clean"), Aliases("cleanup", "Clear"), RequiresBotModRole] + [Description("Removes past events")] + public Task ClearGeneric(CommandContext ctx, [Description("Optional year to remove, by default everything before current year")] int? year = null) + => Clear(ctx, year); - [Command("edit"), Aliases("adjust", "change", "modify", "update"), RequiresBotModRole] - [Description("Updates the event entry properties")] - public Task AdjustGeneric(CommandContext ctx, [Description("Event ID")] int id) - => Update(ctx, id); + [Command("edit"), Aliases("adjust", "change", "modify", "update"), RequiresBotModRole] + [Description("Updates the event entry properties")] + public Task AdjustGeneric(CommandContext ctx, [Description("Event ID")] int id) + => Update(ctx, id); - [Command("schedule"), Aliases("show", "list")] - [Description("Outputs current schedule")] - public Task ListGeneric(CommandContext ctx) - => List(ctx); + [Command("schedule"), Aliases("show", "list")] + [Description("Outputs current schedule")] + public Task ListGeneric(CommandContext ctx) + => List(ctx); - [Command("schedule")] - public Task ListGeneric(CommandContext ctx, - [Description("Optional year to list")] int year) - => List(ctx, null, year); + [Command("schedule")] + public Task ListGeneric(CommandContext ctx, + [Description("Optional year to list")] int year) + => List(ctx, null, year); - [Command("schedule")] - public Task ListGeneric(CommandContext ctx, - [Description("Optional event name to list schedule for")] string eventName) - => List(ctx, eventName); + [Command("schedule")] + public Task ListGeneric(CommandContext ctx, + [Description("Optional event name to list schedule for")] string eventName) + => List(ctx, eventName); - [Command("schedule")] - public Task ListGeneric(CommandContext ctx, - [Description("Optional event name to list schedule for")] string eventName, - [Description("Optional year to list")] int year) - => List(ctx, eventName, year); + [Command("schedule")] + public Task ListGeneric(CommandContext ctx, + [Description("Optional event name to list schedule for")] string eventName, + [Description("Optional year to list")] int year) + => List(ctx, eventName, year); - [Command("countdown")] - [Description("Provides countdown for the nearest known event")] - public Task Countdown(CommandContext ctx, string? eventName = null) - => NearestEvent(ctx, eventName); - } -} + [Command("countdown")] + [Description("Provides countdown for the nearest known event")] + public Task Countdown(CommandContext ctx, string? eventName = null) + => NearestEvent(ctx, eventName); +} \ No newline at end of file diff --git a/CompatBot/Commands/EventsBaseCommand.cs b/CompatBot/Commands/EventsBaseCommand.cs index 03073233..37f7fbce 100644 --- a/CompatBot/Commands/EventsBaseCommand.cs +++ b/CompatBot/Commands/EventsBaseCommand.cs @@ -17,83 +17,114 @@ using DSharpPlus.EventArgs; using DSharpPlus.Interactivity.Extensions; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands -{ - internal class EventsBaseCommand: BaseCommandModuleCustom - { - private static readonly TimeSpan InteractTimeout = TimeSpan.FromMinutes(5); - private static readonly Regex Duration = new(@"((?\d+)(\.|d\s*))?((?\d+)(\:|h\s*))?((?\d+)m?)?", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture); +namespace CompatBot.Commands; - protected static async Task NearestEvent(CommandContext ctx, string? eventName = null) +internal class EventsBaseCommand: BaseCommandModuleCustom +{ + private static readonly TimeSpan InteractTimeout = TimeSpan.FromMinutes(5); + private static readonly Regex Duration = new(@"((?\d+)(\.|d\s*))?((?\d+)(\:|h\s*))?((?\d+)m?)?", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture); + + protected static async Task NearestEvent(CommandContext ctx, string? eventName = null) + { + var originalEventName = eventName = eventName?.Trim(40); + var current = DateTime.UtcNow; + var currentTicks = current.Ticks; + await using var db = new BotDb(); + var currentEvents = await db.EventSchedule.OrderBy(e => e.End).Where(e => e.Start <= currentTicks && e.End >= currentTicks).ToListAsync().ConfigureAwait(false); + var nextEvent = await db.EventSchedule.OrderBy(e => e.Start).FirstOrDefaultAsync(e => e.Start > currentTicks).ConfigureAwait(false); + if (string.IsNullOrEmpty(eventName)) { - var originalEventName = eventName = eventName?.Trim(40); - var current = DateTime.UtcNow; - var currentTicks = current.Ticks; - await using var db = new BotDb(); - var currentEvents = await db.EventSchedule.OrderBy(e => e.End).Where(e => e.Start <= currentTicks && e.End >= currentTicks).ToListAsync().ConfigureAwait(false); - var nextEvent = await db.EventSchedule.OrderBy(e => e.Start).FirstOrDefaultAsync(e => e.Start > currentTicks).ConfigureAwait(false); - if (string.IsNullOrEmpty(eventName)) + var nearestEventMsg = ""; + if (currentEvents.Count > 0) { - var nearestEventMsg = ""; - if (currentEvents.Count > 0) + if (currentEvents.Count == 1) + nearestEventMsg = $"Current event: {currentEvents[0].Name} (going for {FormatCountdown(current - currentEvents[0].Start.AsUtc())})\n"; + else { - if (currentEvents.Count == 1) - nearestEventMsg = $"Current event: {currentEvents[0].Name} (going for {FormatCountdown(current - currentEvents[0].Start.AsUtc())})\n"; - else - { - nearestEventMsg = "Current events:\n"; - foreach (var e in currentEvents) - nearestEventMsg += $"{e.Name} (going for {FormatCountdown(current - e.Start.AsUtc())})\n"; - } + nearestEventMsg = "Current events:\n"; + foreach (var e in currentEvents) + nearestEventMsg += $"{e.Name} (going for {FormatCountdown(current - e.Start.AsUtc())})\n"; } - if (nextEvent != null) - nearestEventMsg += $"Next event: {nextEvent.Name} (starts in {FormatCountdown(nextEvent.Start.AsUtc() - current)})"; - await ctx.Channel.SendMessageAsync(nearestEventMsg.TrimEnd()).ConfigureAwait(false); + } + if (nextEvent != null) + nearestEventMsg += $"Next event: {nextEvent.Name} (starts in {FormatCountdown(nextEvent.Start.AsUtc() - current)})"; + await ctx.Channel.SendMessageAsync(nearestEventMsg.TrimEnd()).ConfigureAwait(false); + return; + } + + eventName = await FuzzyMatchEventName(db, eventName).ConfigureAwait(false); + var promo = ""; + if (currentEvents.Count > 0) + promo = $"\nMeanwhile check out this {(string.IsNullOrEmpty(currentEvents[0].EventName) ? "" : currentEvents[0].EventName + " " + currentEvents[0].Year + " ")}event in progress: {currentEvents[0].Name} (going for {FormatCountdown(current - currentEvents[0].Start.AsUtc())})"; + else if (nextEvent != null) + promo = $"\nMeanwhile check out this upcoming {(string.IsNullOrEmpty(nextEvent.EventName) ? "" : nextEvent.EventName + " " + nextEvent.Year + " ")}event: {nextEvent.Name} (starts in {FormatCountdown(nextEvent.Start.AsUtc() - current)})"; + var firstNamedEvent = await db.EventSchedule.OrderBy(e => e.Start).FirstOrDefaultAsync(e => e.Year >= current.Year && e.EventName == eventName).ConfigureAwait(false); + if (firstNamedEvent == null) + { + var scheduleEntry = await FuzzyMatchEntryName(db, originalEventName).ConfigureAwait(false); + var events = await db.EventSchedule.OrderBy(e => e.Start).Where(e => e.End > current.Ticks && e.Name == scheduleEntry).ToListAsync().ConfigureAwait(false); + if (events.Any()) + { + var eventListMsg = new StringBuilder(); + foreach (var eventEntry in events) + { + if (eventEntry.Start < current.Ticks) + eventListMsg.AppendLine($"{eventEntry.Name} ends in {FormatCountdown(eventEntry.End.AsUtc() - current)}"); + else + eventListMsg.AppendLine($"{eventEntry.Name} starts in {FormatCountdown(eventEntry.Start.AsUtc() - current)}"); + } + await ctx.SendAutosplitMessageAsync(eventListMsg.ToString(), blockStart: "", blockEnd: "").ConfigureAwait(false); return; } - eventName = await FuzzyMatchEventName(db, eventName).ConfigureAwait(false); - var promo = ""; - if (currentEvents.Count > 0) - promo = $"\nMeanwhile check out this {(string.IsNullOrEmpty(currentEvents[0].EventName) ? "" : currentEvents[0].EventName + " " + currentEvents[0].Year + " ")}event in progress: {currentEvents[0].Name} (going for {FormatCountdown(current - currentEvents[0].Start.AsUtc())})"; - else if (nextEvent != null) - promo = $"\nMeanwhile check out this upcoming {(string.IsNullOrEmpty(nextEvent.EventName) ? "" : nextEvent.EventName + " " + nextEvent.Year + " ")}event: {nextEvent.Name} (starts in {FormatCountdown(nextEvent.Start.AsUtc() - current)})"; - var firstNamedEvent = await db.EventSchedule.OrderBy(e => e.Start).FirstOrDefaultAsync(e => e.Year >= current.Year && e.EventName == eventName).ConfigureAwait(false); - if (firstNamedEvent == null) + var noEventMsg = $"No information about the upcoming {eventName?.Sanitize(replaceBackTicks: true)} at the moment"; + if (eventName?.Length > 10) + noEventMsg = "No information about such event at the moment"; + else if (ctx.User.Id == 259997001880436737ul || ctx.User.Id == 377190919327318018ul) { - var scheduleEntry = await FuzzyMatchEntryName(db, originalEventName).ConfigureAwait(false); - var events = await db.EventSchedule.OrderBy(e => e.Start).Where(e => e.End > current.Ticks && e.Name == scheduleEntry).ToListAsync().ConfigureAwait(false); - if (events.Any()) + noEventMsg = $"Haha, very funny, {ctx.User.Mention}. So original. Never saw this joke before."; + promo = null; + } + if (!string.IsNullOrEmpty(promo)) + noEventMsg += promo; + await ctx.Channel.SendMessageAsync(noEventMsg).ConfigureAwait(false); + return; + } + + if (firstNamedEvent.Start >= currentTicks) + { + var upcomingNamedEventMsg = $"__{FormatCountdown(firstNamedEvent.Start.AsUtc() - current)} until {eventName} {firstNamedEvent.Year}!__"; + if (string.IsNullOrEmpty(promo) || nextEvent?.Id == firstNamedEvent.Id) + upcomingNamedEventMsg += $"\nFirst event: {firstNamedEvent.Name}"; + else + upcomingNamedEventMsg += promo; + await ctx.Channel.SendMessageAsync(upcomingNamedEventMsg).ConfigureAwait(false); + return; + } + + var lastNamedEvent = await db.EventSchedule.OrderByDescending(e => e.End).FirstOrDefaultAsync(e => e.Year == current.Year && e.EventName == eventName).ConfigureAwait(false); + if (lastNamedEvent is not null && lastNamedEvent.End <= currentTicks) + { + if (lastNamedEvent.End < current.AddMonths(-1).Ticks) + { + firstNamedEvent = await db.EventSchedule.OrderBy(e => e.Start).FirstOrDefaultAsync(e => e.Year >= current.Year + 1 && e.EventName == eventName).ConfigureAwait(false); + if (firstNamedEvent == null) { - var eventListMsg = new StringBuilder(); - foreach (var eventEntry in events) + var noEventMsg = $"No information about the upcoming {eventName?.Sanitize(replaceBackTicks: true)} at the moment"; + if (eventName?.Length > 10) + noEventMsg = "No information about such event at the moment"; + else if (ctx.User.Id == 259997001880436737ul || ctx.User.Id == 377190919327318018ul) { - if (eventEntry.Start < current.Ticks) - eventListMsg.AppendLine($"{eventEntry.Name} ends in {FormatCountdown(eventEntry.End.AsUtc() - current)}"); - else - eventListMsg.AppendLine($"{eventEntry.Name} starts in {FormatCountdown(eventEntry.Start.AsUtc() - current)}"); + noEventMsg = $"Haha, very funny, {ctx.User.Mention}. So original. Never saw this joke before."; + promo = null; } - await ctx.SendAutosplitMessageAsync(eventListMsg.ToString(), blockStart: "", blockEnd: "").ConfigureAwait(false); + if (!string.IsNullOrEmpty(promo)) + noEventMsg += promo; + await ctx.Channel.SendMessageAsync(noEventMsg).ConfigureAwait(false); return; } - - var noEventMsg = $"No information about the upcoming {eventName?.Sanitize(replaceBackTicks: true)} at the moment"; - if (eventName?.Length > 10) - noEventMsg = "No information about such event at the moment"; - else if (ctx.User.Id == 259997001880436737ul || ctx.User.Id == 377190919327318018ul) - { - noEventMsg = $"Haha, very funny, {ctx.User.Mention}. So original. Never saw this joke before."; - promo = null; - } - if (!string.IsNullOrEmpty(promo)) - noEventMsg += promo; - await ctx.Channel.SendMessageAsync(noEventMsg).ConfigureAwait(false); - return; - } - - if (firstNamedEvent.Start >= currentTicks) - { + var upcomingNamedEventMsg = $"__{FormatCountdown(firstNamedEvent.Start.AsUtc() - current)} until {eventName} {firstNamedEvent.Year}!__"; if (string.IsNullOrEmpty(promo) || nextEvent?.Id == firstNamedEvent.Id) upcomingNamedEventMsg += $"\nFirst event: {firstNamedEvent.Name}"; @@ -103,517 +134,485 @@ namespace CompatBot.Commands return; } - var lastNamedEvent = await db.EventSchedule.OrderByDescending(e => e.End).FirstOrDefaultAsync(e => e.Year == current.Year && e.EventName == eventName).ConfigureAwait(false); - if (lastNamedEvent is not null && lastNamedEvent.End <= currentTicks) - { - if (lastNamedEvent.End < current.AddMonths(-1).Ticks) - { - firstNamedEvent = await db.EventSchedule.OrderBy(e => e.Start).FirstOrDefaultAsync(e => e.Year >= current.Year + 1 && e.EventName == eventName).ConfigureAwait(false); - if (firstNamedEvent == null) - { - var noEventMsg = $"No information about the upcoming {eventName?.Sanitize(replaceBackTicks: true)} at the moment"; - if (eventName?.Length > 10) - noEventMsg = "No information about such event at the moment"; - else if (ctx.User.Id == 259997001880436737ul || ctx.User.Id == 377190919327318018ul) - { - noEventMsg = $"Haha, very funny, {ctx.User.Mention}. So original. Never saw this joke before."; - promo = null; - } - if (!string.IsNullOrEmpty(promo)) - noEventMsg += promo; - await ctx.Channel.SendMessageAsync(noEventMsg).ConfigureAwait(false); - return; - } - - var upcomingNamedEventMsg = $"__{FormatCountdown(firstNamedEvent.Start.AsUtc() - current)} until {eventName} {firstNamedEvent.Year}!__"; - if (string.IsNullOrEmpty(promo) || nextEvent?.Id == firstNamedEvent.Id) - upcomingNamedEventMsg += $"\nFirst event: {firstNamedEvent.Name}"; - else - upcomingNamedEventMsg += promo; - await ctx.Channel.SendMessageAsync(upcomingNamedEventMsg).ConfigureAwait(false); - return; - } - - var e3EndedMsg = $"__{eventName} {current.Year} has concluded. See you next year! (maybe)__"; - if (!string.IsNullOrEmpty(promo)) - e3EndedMsg += promo; - await ctx.Channel.SendMessageAsync(e3EndedMsg).ConfigureAwait(false); - return; - } - - var currentNamedEvent = await db.EventSchedule.OrderBy(e => e.End).FirstOrDefaultAsync(e => e.Start <= currentTicks && e.End >= currentTicks && e.EventName == eventName).ConfigureAwait(false); - var nextNamedEvent = await db.EventSchedule.OrderBy(e => e.Start).FirstOrDefaultAsync(e => e.Start > currentTicks && e.EventName == eventName).ConfigureAwait(false); - var msg = $"__{eventName} {current.Year} is already in progress!__\n"; - if (currentNamedEvent != null) - msg += $"Current event: {currentNamedEvent.Name} (going for {FormatCountdown(current - currentNamedEvent.Start.AsUtc())})\n"; - if (nextNamedEvent != null) - msg += $"Next event: {nextNamedEvent.Name} (starts in {FormatCountdown(nextNamedEvent.Start.AsUtc() - current)})"; - await ctx.SendAutosplitMessageAsync(msg.TrimEnd(), blockStart: "", blockEnd: "").ConfigureAwait(false); + var e3EndedMsg = $"__{eventName} {current.Year} has concluded. See you next year! (maybe)__"; + if (!string.IsNullOrEmpty(promo)) + e3EndedMsg += promo; + await ctx.Channel.SendMessageAsync(e3EndedMsg).ConfigureAwait(false); + return; } - protected static async Task Add(CommandContext ctx, string? eventName = null) + var currentNamedEvent = await db.EventSchedule.OrderBy(e => e.End).FirstOrDefaultAsync(e => e.Start <= currentTicks && e.End >= currentTicks && e.EventName == eventName).ConfigureAwait(false); + var nextNamedEvent = await db.EventSchedule.OrderBy(e => e.Start).FirstOrDefaultAsync(e => e.Start > currentTicks && e.EventName == eventName).ConfigureAwait(false); + var msg = $"__{eventName} {current.Year} is already in progress!__\n"; + if (currentNamedEvent != null) + msg += $"Current event: {currentNamedEvent.Name} (going for {FormatCountdown(current - currentNamedEvent.Start.AsUtc())})\n"; + if (nextNamedEvent != null) + msg += $"Next event: {nextNamedEvent.Name} (starts in {FormatCountdown(nextNamedEvent.Start.AsUtc() - current)})"; + await ctx.SendAutosplitMessageAsync(msg.TrimEnd(), blockStart: "", blockEnd: "").ConfigureAwait(false); + } + + protected static async Task Add(CommandContext ctx, string? eventName = null) + { + var evt = new EventSchedule(); + var (success, msg) = await EditEventPropertiesAsync(ctx, evt, eventName).ConfigureAwait(false); + if (success) { - var evt = new EventSchedule(); - var (success, msg) = await EditEventPropertiesAsync(ctx, evt, eventName).ConfigureAwait(false); - if (success) - { - await using var db = new BotDb(); - await db.EventSchedule.AddAsync(evt).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - if (LimitedToSpamChannel.IsSpamChannel(ctx.Channel)) - await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatEvent(evt).WithTitle("Created new event schedule entry #" + evt.Id)).ConfigureAwait(false); - else - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Added a new schedule entry").ConfigureAwait(false); - } + await using var db = new BotDb(); + await db.EventSchedule.AddAsync(evt).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + if (LimitedToSpamChannel.IsSpamChannel(ctx.Channel)) + await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatEvent(evt).WithTitle("Created new event schedule entry #" + evt.Id)).ConfigureAwait(false); else - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Event creation aborted").ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Added a new schedule entry").ConfigureAwait(false); + } + else + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Event creation aborted").ConfigureAwait(false); + } + + protected static async Task Remove(CommandContext ctx, params int[] ids) + { + await using var db = new BotDb(); + var eventsToRemove = await db.EventSchedule.Where(e3e => ids.Contains(e3e.Id)).ToListAsync().ConfigureAwait(false); + db.EventSchedule.RemoveRange(eventsToRemove); + var removedCount = await db.SaveChangesAsync().ConfigureAwait(false); + if (removedCount == ids.Length) + await ctx.Channel.SendMessageAsync($"Event{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); + else + await ctx.Channel.SendMessageAsync($"Removed {removedCount} event{StringUtils.GetSuffix(removedCount)}, but was asked to remove {ids.Length}").ConfigureAwait(false); + } + + protected static async Task Clear(CommandContext ctx, int? year = null) + { + var currentYear = DateTime.UtcNow.Year; + await using var db = new BotDb(); + var itemsToRemove = await db.EventSchedule.Where(e => + year.HasValue + ? e.Year == year + : e.Year < currentYear + ).ToListAsync().ConfigureAwait(false); + db.EventSchedule.RemoveRange(itemsToRemove); + var removedCount = await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"Removed {removedCount} event{(removedCount == 1 ? "" : "s")}").ConfigureAwait(false); + } + + protected static async Task Update(CommandContext ctx, int id, string? eventName = null) + { + await using var db = new BotDb(); + var evt = eventName == null + ? db.EventSchedule.FirstOrDefault(e => e.Id == id) + : db.EventSchedule.FirstOrDefault(e => e.Id == id && e.EventName == eventName); + if (evt == null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, $"No event with id {id}").ConfigureAwait(false); + return; } - protected static async Task Remove(CommandContext ctx, params int[] ids) + var (success, msg) = await EditEventPropertiesAsync(ctx, evt, eventName).ConfigureAwait(false); + if (success) { - await using var db = new BotDb(); - var eventsToRemove = await db.EventSchedule.Where(e3e => ids.Contains(e3e.Id)).ToListAsync().ConfigureAwait(false); - db.EventSchedule.RemoveRange(eventsToRemove); - var removedCount = await db.SaveChangesAsync().ConfigureAwait(false); - if (removedCount == ids.Length) - await ctx.Channel.SendMessageAsync($"Event{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + if (LimitedToSpamChannel.IsSpamChannel(ctx.Channel)) + await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatEvent(evt).WithTitle("Updated event schedule entry #" + evt.Id)).ConfigureAwait(false); else - await ctx.Channel.SendMessageAsync($"Removed {removedCount} event{StringUtils.GetSuffix(removedCount)}, but was asked to remove {ids.Length}").ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Updated the schedule entry").ConfigureAwait(false); + } + else + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Event update aborted, changes weren't saved").ConfigureAwait(false); + } + + protected static async Task List(CommandContext ctx, string? eventName = null, int? year = null) + { + var showAll = "all".Equals(eventName, StringComparison.InvariantCultureIgnoreCase); + var currentTicks = DateTime.UtcNow.Ticks; + await using var db = new BotDb(); + IQueryable query = db.EventSchedule; + if (year.HasValue) + query = query.Where(e => e.Year == year); + else if (!showAll) + query = query.Where(e => e.End > currentTicks); + if (!string.IsNullOrEmpty(eventName) && !showAll) + { + eventName = await FuzzyMatchEventName(db, eventName).ConfigureAwait(false); + query = query.Where(e => e.EventName == eventName); + } + List events = await query + .OrderBy(e => e.Start) + .ToListAsync() + .ConfigureAwait(false); + if (events.Count == 0) + { + await ctx.Channel.SendMessageAsync("There are no events to show").ConfigureAwait(false); + return; } - protected static async Task Clear(CommandContext ctx, int? year = null) + var msg = new StringBuilder(); + var currentYear = -1; + var currentEvent = Guid.NewGuid().ToString(); + foreach (var evt in events) { - var currentYear = DateTime.UtcNow.Year; - await using var db = new BotDb(); - var itemsToRemove = await db.EventSchedule.Where(e => - year.HasValue - ? e.Year == year - : e.Year < currentYear - ).ToListAsync().ConfigureAwait(false); - db.EventSchedule.RemoveRange(itemsToRemove); - var removedCount = await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"Removed {removedCount} event{(removedCount == 1 ? "" : "s")}").ConfigureAwait(false); + if (evt.Year != currentYear) + { + if (currentYear > 0) + msg.AppendLine().AppendLine($"__Year {evt.Year}__"); + currentEvent = Guid.NewGuid().ToString(); + currentYear = evt.Year; + } + + var evtName = evt.EventName ?? ""; + if (currentEvent != evtName) + { + currentEvent = evtName; + var printName = string.IsNullOrEmpty(currentEvent) ? "Various independent events" : $"**{currentEvent} {currentYear} schedule**"; + msg.AppendLine($"{printName} (UTC):"); + } + msg.Append(StringUtils.InvisibleSpacer).Append('`'); + if (ModProvider.IsMod(ctx.Message.Author.Id)) + msg.Append($"[{evt.Id:0000}] "); + msg.Append($"{evt.Start.AsUtc():u}"); + if (ctx.Channel.IsPrivate) + msg.Append($@" - {evt.End.AsUtc():u}"); + msg.AppendLine($@" ({evt.End.AsUtc() - evt.Start.AsUtc():h\:mm})`: {evt.Name}"); } + var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); + await ch.SendAutosplitMessageAsync(msg, blockStart: "", blockEnd: "").ConfigureAwait(false); + } - protected static async Task Update(CommandContext ctx, int id, string? eventName = null) - { - await using var db = new BotDb(); - var evt = eventName == null - ? db.EventSchedule.FirstOrDefault(e => e.Id == id) - : db.EventSchedule.FirstOrDefault(e => e.Id == id && e.EventName == eventName); - if (evt == null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, $"No event with id {id}").ConfigureAwait(false); - return; - } + private static async Task<(bool success, DiscordMessage? message)> EditEventPropertiesAsync(CommandContext ctx, EventSchedule evt, string? eventName = null) + { + var interact = ctx.Client.GetInteractivity(); + var abort = new DiscordButtonComponent(ButtonStyle.Danger, "event:edit:abort", "Cancel", emoji: new(DiscordEmoji.FromUnicode("✖"))); + var lastPage = new DiscordButtonComponent(ButtonStyle.Secondary, "event:edit:last", "To Last Field", emoji: new(DiscordEmoji.FromUnicode("⏭"))); + var firstPage = new DiscordButtonComponent(ButtonStyle.Secondary, "event:edit:first", "To First Field", emoji: new(DiscordEmoji.FromUnicode("⏮"))); + var previousPage = new DiscordButtonComponent(ButtonStyle.Secondary, "event:edit:previous", "Previous", emoji: new(DiscordEmoji.FromUnicode("◀"))); + var nextPage = new DiscordButtonComponent(ButtonStyle.Primary, "event:edit:next", "Next", emoji: new(DiscordEmoji.FromUnicode("▶"))); + var trash = new DiscordButtonComponent(ButtonStyle.Secondary, "event:edit:trash", "Clear", emoji: new(DiscordEmoji.FromUnicode("🗑"))); + var saveEdit = new DiscordButtonComponent(ButtonStyle.Success, "event:edit:save", "Save", emoji: new(DiscordEmoji.FromUnicode("💾"))); - var (success, msg) = await EditEventPropertiesAsync(ctx, evt, eventName).ConfigureAwait(false); - if (success) - { - await db.SaveChangesAsync().ConfigureAwait(false); - if (LimitedToSpamChannel.IsSpamChannel(ctx.Channel)) - await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatEvent(evt).WithTitle("Updated event schedule entry #" + evt.Id)).ConfigureAwait(false); - else - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Updated the schedule entry").ConfigureAwait(false); - } - else - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Event update aborted, changes weren't saved").ConfigureAwait(false); - } - - protected static async Task List(CommandContext ctx, string? eventName = null, int? year = null) - { - var showAll = "all".Equals(eventName, StringComparison.InvariantCultureIgnoreCase); - var currentTicks = DateTime.UtcNow.Ticks; - await using var db = new BotDb(); - IQueryable query = db.EventSchedule; - if (year.HasValue) - query = query.Where(e => e.Year == year); - else if (!showAll) - query = query.Where(e => e.End > currentTicks); - if (!string.IsNullOrEmpty(eventName) && !showAll) - { - eventName = await FuzzyMatchEventName(db, eventName).ConfigureAwait(false); - query = query.Where(e => e.EventName == eventName); - } - List events = await query - .OrderBy(e => e.Start) - .ToListAsync() - .ConfigureAwait(false); - if (events.Count == 0) - { - await ctx.Channel.SendMessageAsync("There are no events to show").ConfigureAwait(false); - return; - } - - var msg = new StringBuilder(); - var currentYear = -1; - var currentEvent = Guid.NewGuid().ToString(); - foreach (var evt in events) - { - if (evt.Year != currentYear) - { - if (currentYear > 0) - msg.AppendLine().AppendLine($"__Year {evt.Year}__"); - currentEvent = Guid.NewGuid().ToString(); - currentYear = evt.Year; - } - - var evtName = evt.EventName ?? ""; - if (currentEvent != evtName) - { - currentEvent = evtName; - var printName = string.IsNullOrEmpty(currentEvent) ? "Various independent events" : $"**{currentEvent} {currentYear} schedule**"; - msg.AppendLine($"{printName} (UTC):"); - } - msg.Append(StringUtils.InvisibleSpacer).Append('`'); - if (ModProvider.IsMod(ctx.Message.Author.Id)) - msg.Append($"[{evt.Id:0000}] "); - msg.Append($"{evt.Start.AsUtc():u}"); - if (ctx.Channel.IsPrivate) - msg.Append($@" - {evt.End.AsUtc():u}"); - msg.AppendLine($@" ({evt.End.AsUtc() - evt.Start.AsUtc():h\:mm})`: {evt.Name}"); - } - var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); - await ch.SendAutosplitMessageAsync(msg, blockStart: "", blockEnd: "").ConfigureAwait(false); - } - - private static async Task<(bool success, DiscordMessage? message)> EditEventPropertiesAsync(CommandContext ctx, EventSchedule evt, string? eventName = null) - { - var interact = ctx.Client.GetInteractivity(); - var abort = new DiscordButtonComponent(ButtonStyle.Danger, "event:edit:abort", "Cancel", emoji: new(DiscordEmoji.FromUnicode("✖"))); - var lastPage = new DiscordButtonComponent(ButtonStyle.Secondary, "event:edit:last", "To Last Field", emoji: new(DiscordEmoji.FromUnicode("⏭"))); - var firstPage = new DiscordButtonComponent(ButtonStyle.Secondary, "event:edit:first", "To First Field", emoji: new(DiscordEmoji.FromUnicode("⏮"))); - var previousPage = new DiscordButtonComponent(ButtonStyle.Secondary, "event:edit:previous", "Previous", emoji: new(DiscordEmoji.FromUnicode("◀"))); - var nextPage = new DiscordButtonComponent(ButtonStyle.Primary, "event:edit:next", "Next", emoji: new(DiscordEmoji.FromUnicode("▶"))); - var trash = new DiscordButtonComponent(ButtonStyle.Secondary, "event:edit:trash", "Clear", emoji: new(DiscordEmoji.FromUnicode("🗑"))); - var saveEdit = new DiscordButtonComponent(ButtonStyle.Success, "event:edit:save", "Save", emoji: new(DiscordEmoji.FromUnicode("💾"))); - - var skipEventNameStep = !string.IsNullOrEmpty(eventName); - DiscordMessage? msg = null; - string? errorMsg = null; - DiscordMessage? txt; - ComponentInteractionCreateEventArgs? btn; + var skipEventNameStep = !string.IsNullOrEmpty(eventName); + DiscordMessage? msg = null; + string? errorMsg = null; + DiscordMessage? txt; + ComponentInteractionCreateEventArgs? btn; step1: - // step 1: get the new start date - saveEdit.SetEnabled(evt.IsComplete()); - var messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify a new **start date and time**") - .WithEmbed(FormatEvent(evt, errorMsg, 1).WithDescription($"Example: `{DateTime.UtcNow:yyyy-MM-dd HH:mm} PST`\nBy default all times use UTC, only limited number of time zones supported")) - .AddComponents(lastPage, nextPage) - .AddComponents(saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == lastPage.CustomId) - goto step4; - } - else if (txt != null) - { - if (!TimeParser.TryParse(txt.Content, out var newTime)) - { - errorMsg = $"Couldn't parse `{txt.Content}` as a start date and time"; - goto step1; - } - if (newTime < DateTime.UtcNow && evt.End == default) - errorMsg = "Specified time is in the past, are you sure it is correct?"; - - var duration = evt.End - evt.Start; - evt.Start = newTime.Ticks; - evt.End = evt.Start + duration; - evt.Year = newTime.Year; - } - else + // step 1: get the new start date + saveEdit.SetEnabled(evt.IsComplete()); + var messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify a new **start date and time**") + .WithEmbed(FormatEvent(evt, errorMsg, 1).WithDescription($"Example: `{DateTime.UtcNow:yyyy-MM-dd HH:mm} PST`\nBy default all times use UTC, only limited number of time zones supported")) + .AddComponents(lastPage, nextPage) + .AddComponents(saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) return (false, msg); + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == lastPage.CustomId) + goto step4; + } + else if (txt != null) + { + if (!TimeParser.TryParse(txt.Content, out var newTime)) + { + errorMsg = $"Couldn't parse `{txt.Content}` as a start date and time"; + goto step1; + } + if (newTime < DateTime.UtcNow && evt.End == default) + errorMsg = "Specified time is in the past, are you sure it is correct?"; + + var duration = evt.End - evt.Start; + evt.Start = newTime.Ticks; + evt.End = evt.Start + duration; + evt.Year = newTime.Year; + } + else + return (false, msg); + step2: - // step 2: get the new duration - saveEdit.SetEnabled(evt.IsComplete()); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify a new **event duration**") - .WithEmbed(FormatEvent(evt, errorMsg, 2).WithDescription("Example: `2d 1h 15m`, or `2.1:00`")) - .AddComponents(previousPage, nextPage) - .AddComponents(saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - goto step1; - - if (skipEventNameStep) - goto step4; - } - else if (txt != null) - { - var newLength = await TryParseTimeSpanAsync(ctx, txt.Content, false).ConfigureAwait(false); - if (!newLength.HasValue) - { - errorMsg = $"Couldn't parse `{txt.Content}` as a duration"; - goto step2; - } - - evt.End = (evt.Start.AsUtc() + newLength.Value).Ticks; - } - else + // step 2: get the new duration + saveEdit.SetEnabled(evt.IsComplete()); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify a new **event duration**") + .WithEmbed(FormatEvent(evt, errorMsg, 2).WithDescription("Example: `2d 1h 15m`, or `2.1:00`")) + .AddComponents(previousPage, nextPage) + .AddComponents(saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) return (false, msg); + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + goto step1; + + if (skipEventNameStep) + goto step4; + } + else if (txt != null) + { + var newLength = await TryParseTimeSpanAsync(ctx, txt.Content, false).ConfigureAwait(false); + if (!newLength.HasValue) + { + errorMsg = $"Couldn't parse `{txt.Content}` as a duration"; + goto step2; + } + + evt.End = (evt.Start.AsUtc() + newLength.Value).Ticks; + } + else + return (false, msg); + step3: - // step 3: get the new event name - saveEdit.SetEnabled(evt.IsComplete()); - trash.SetDisabled(string.IsNullOrEmpty(evt.EventName)); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify a new **event name**") - .WithEmbed(FormatEvent(evt, errorMsg, 3)) - .AddComponents(previousPage, nextPage, trash) - .AddComponents(saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - goto step2; - - if (btn.Id == trash.CustomId) - evt.EventName = null; - } - else if (txt != null) - evt.EventName = string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-" ? null : txt.Content; - else + // step 3: get the new event name + saveEdit.SetEnabled(evt.IsComplete()); + trash.SetDisabled(string.IsNullOrEmpty(evt.EventName)); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify a new **event name**") + .WithEmbed(FormatEvent(evt, errorMsg, 3)) + .AddComponents(previousPage, nextPage, trash) + .AddComponents(saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) return (false, msg); + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + goto step2; + + if (btn.Id == trash.CustomId) + evt.EventName = null; + } + else if (txt != null) + evt.EventName = string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-" ? null : txt.Content; + else + return (false, msg); + step4: - // step 4: get the new schedule entry name - saveEdit.SetEnabled(evt.IsComplete()); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Please specify a new **schedule entry title**") - .WithEmbed(FormatEvent(evt, errorMsg, 4)) - .AddComponents(previousPage, firstPage) - .AddComponents(saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == firstPage.CustomId) - goto step1; - - if (btn.Id == previousPage.CustomId) - { - if (skipEventNameStep) - goto step2; - goto step3; - } - } - else if (txt != null) - { - if (string.IsNullOrEmpty(txt.Content)) - { - errorMsg = "Entry title cannot be empty"; - goto step4; - } - - evt.Name = txt.Content; - } - else + // step 4: get the new schedule entry name + saveEdit.SetEnabled(evt.IsComplete()); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Please specify a new **schedule entry title**") + .WithEmbed(FormatEvent(evt, errorMsg, 4)) + .AddComponents(previousPage, firstPage) + .AddComponents(saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) return (false, msg); + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == firstPage.CustomId) + goto step1; + + if (btn.Id == previousPage.CustomId) + { + if (skipEventNameStep) + goto step2; + goto step3; + } + } + else if (txt != null) + { + if (string.IsNullOrEmpty(txt.Content)) + { + errorMsg = "Entry title cannot be empty"; + goto step4; + } + + evt.Name = txt.Content; + } + else + return (false, msg); + step5: - // step 5: confirm - if (errorMsg == null && !evt.IsComplete()) - errorMsg = "Some required properties are not defined"; - saveEdit.SetEnabled(evt.IsComplete()); - messageBuilder = new DiscordMessageBuilder() - .WithContent("Does this look good? (y/n)") - .WithEmbed(FormatEvent(evt, errorMsg)) - .AddComponents(previousPage, firstPage) - .AddComponents(saveEdit, abort); - errorMsg = null; - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); - if (btn != null) - { - if (btn.Id == abort.CustomId) - return (false, msg); - - if (btn.Id == saveEdit.CustomId) - return (true, msg); - - if (btn.Id == previousPage.CustomId) - goto step4; - - if (btn.Id == firstPage.CustomId) - goto step1; - } - else if (!string.IsNullOrEmpty(txt?.Content)) - { - if (!evt.IsComplete()) - goto step5; - - switch (txt.Content.ToLowerInvariant()) - { - case "yes": - case "y": - case "✅": - case "☑": - case "✔": - case "👌": - case "👍": - return (true, msg); - case "no": - case "n": - case "❎": - case "❌": - case "👎": - return (false, msg); - default: - errorMsg = "I don't know what you mean, so I'll just abort"; - goto step5; - } - } - else - { + // step 5: confirm + if (errorMsg == null && !evt.IsComplete()) + errorMsg = "Some required properties are not defined"; + saveEdit.SetEnabled(evt.IsComplete()); + messageBuilder = new DiscordMessageBuilder() + .WithContent("Does this look good? (y/n)") + .WithEmbed(FormatEvent(evt, errorMsg)) + .AddComponents(previousPage, firstPage) + .AddComponents(saveEdit, abort); + errorMsg = null; + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + (txt, btn) = await interact.WaitForMessageOrButtonAsync(msg, ctx.User, InteractTimeout).ConfigureAwait(false); + if (btn != null) + { + if (btn.Id == abort.CustomId) return (false, msg); - } + if (btn.Id == saveEdit.CustomId) + return (true, msg); + + if (btn.Id == previousPage.CustomId) + goto step4; + + if (btn.Id == firstPage.CustomId) + goto step1; + } + else if (!string.IsNullOrEmpty(txt?.Content)) + { + if (!evt.IsComplete()) + goto step5; + + switch (txt.Content.ToLowerInvariant()) + { + case "yes": + case "y": + case "✅": + case "☑": + case "✔": + case "👌": + case "👍": + return (true, msg); + case "no": + case "n": + case "❎": + case "❌": + case "👎": + return (false, msg); + default: + errorMsg = "I don't know what you mean, so I'll just abort"; + goto step5; + } + } + else + { return (false, msg); } - private static string? NameWithoutLink(string? name) - { - if (string.IsNullOrEmpty(name)) - return name; - - var lastPartIdx = name.LastIndexOf(' '); - if (lastPartIdx < 0) - return name; - - if (name.Length - lastPartIdx > 5 - && name.Substring(lastPartIdx + 1, 5).ToLowerInvariant() is string lnk - && (lnk.StartsWith(" FuzzyMatchEventName(BotDb db, string? eventName) - { - var knownEventNames = await db.EventSchedule.Select(e => e.EventName).Distinct().ToListAsync().ConfigureAwait(false); - var (score, name) = knownEventNames.Select(n => (score: eventName.GetFuzzyCoefficientCached(n), name: n)).OrderByDescending(t => t.score).FirstOrDefault(); - return score > 0.8 ? name : eventName; - } - - private static async Task FuzzyMatchEntryName(BotDb db, string? eventName) - { - var now = DateTime.UtcNow.Ticks; - var knownNames = await db.EventSchedule.Where(e => e.End > now).Select(e => e.Name).ToListAsync().ConfigureAwait(false); - var (score, name) = knownNames.Select(n => (score: eventName.GetFuzzyCoefficientCached(NameWithoutLink(n)), name: n)).OrderByDescending(t => t.score).FirstOrDefault(); - return score > 0.5 ? name : eventName; - } - - private static async Task TryParseTimeSpanAsync(CommandContext ctx, string duration, bool react = true) - { - var d = Duration.Match(duration); - if (!d.Success) - { - if (react) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to parse `{duration}` as a time", true).ConfigureAwait(false); - return null; - } - - _ = int.TryParse(d.Groups["days"].Value, out var days); - _ = int.TryParse(d.Groups["hours"].Value, out var hours); - _ = int.TryParse(d.Groups["mins"].Value, out var mins); - if (days == 0 && hours == 0 && mins == 0) - { - if (react) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to parse `{duration}` as a time", true).ConfigureAwait(false); - return null; - } - - return new TimeSpan(days, hours, mins, 0); - } - - private static string FormatCountdown(TimeSpan timeSpan) - { - var result = ""; - var days = (int)timeSpan.TotalDays; - if (days > 0) - timeSpan -= TimeSpan.FromDays(days); - var hours = (int)timeSpan.TotalHours; - if (hours > 0) - timeSpan -= TimeSpan.FromHours(hours); - var mins = (int)timeSpan.TotalMinutes; - if (mins > 0) - timeSpan -= TimeSpan.FromMinutes(mins); - var secs = (int)timeSpan.TotalSeconds; - if (days > 0) - result += $"{days} day{(days == 1 ? "" : "s")} "; - if (hours > 0 || days > 0) - result += $"{hours} hour{(hours == 1 ? "" : "s")} "; - if (mins > 0 || hours > 0 || days > 0) - result += $"{mins} minute{(mins == 1 ? "" : "s")} "; - result += $"{secs} second{(secs == 1 ? "" : "s")}"; - return result; - } - - private static DiscordEmbedBuilder FormatEvent(EventSchedule evt, string? error = null, int highlight = -1) - { - var start = evt.Start.AsUtc(); - var field = 1; - var result = new DiscordEmbedBuilder - { - Title = "Schedule entry preview", - Color = string.IsNullOrEmpty(error) ? Config.Colors.Help : Config.Colors.Maintenance, - }; - if (!string.IsNullOrEmpty(error)) - result.AddField("Entry error", error); - var currentTime = DateTime.UtcNow; - if (evt.Start > currentTime.Ticks) - result.WithFooter($"Starts in {FormatCountdown(evt.Start.AsUtc() - currentTime)}"); - else if (evt.End > currentTime.Ticks) - result.WithFooter($"Ends in {FormatCountdown(evt.End.AsUtc() - currentTime)}"); - var eventDuration = evt.End.AsUtc() - start; - var durationFormat = eventDuration.TotalDays > 0 ? @"d\d\ h\h\ m\m" : @"h\h\ m\m"; - var startWarn = start < DateTime.UtcNow ? "⚠ " : ""; - result - .AddFieldEx(startWarn + "Start time", evt.Start == 0 ? "-" : start.ToString("u"), highlight == field++, true) - .AddFieldEx("Duration", evt.Start == evt.End ? "-" : eventDuration.ToString(durationFormat), highlight == field++, true) - .AddFieldEx("Event name", string.IsNullOrEmpty(evt.EventName) ? "-" : evt.EventName, highlight == field++, true) - .AddFieldEx("Schedule entry title", string.IsNullOrEmpty(evt.Name) ? "-" : evt.Name, highlight == field++, true); -#if DEBUG - result.WithFooter("Test bot instance"); -#endif - return result; - } + return (false, msg); } -} + + private static string? NameWithoutLink(string? name) + { + if (string.IsNullOrEmpty(name)) + return name; + + var lastPartIdx = name.LastIndexOf(' '); + if (lastPartIdx < 0) + return name; + + if (name.Length - lastPartIdx > 5 + && name.Substring(lastPartIdx + 1, 5).ToLowerInvariant() is string lnk + && (lnk.StartsWith(" FuzzyMatchEventName(BotDb db, string? eventName) + { + var knownEventNames = await db.EventSchedule.Select(e => e.EventName).Distinct().ToListAsync().ConfigureAwait(false); + var (score, name) = knownEventNames.Select(n => (score: eventName.GetFuzzyCoefficientCached(n), name: n)).OrderByDescending(t => t.score).FirstOrDefault(); + return score > 0.8 ? name : eventName; + } + + private static async Task FuzzyMatchEntryName(BotDb db, string? eventName) + { + var now = DateTime.UtcNow.Ticks; + var knownNames = await db.EventSchedule.Where(e => e.End > now).Select(e => e.Name).ToListAsync().ConfigureAwait(false); + var (score, name) = knownNames.Select(n => (score: eventName.GetFuzzyCoefficientCached(NameWithoutLink(n)), name: n)).OrderByDescending(t => t.score).FirstOrDefault(); + return score > 0.5 ? name : eventName; + } + + private static async Task TryParseTimeSpanAsync(CommandContext ctx, string duration, bool react = true) + { + var d = Duration.Match(duration); + if (!d.Success) + { + if (react) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to parse `{duration}` as a time", true).ConfigureAwait(false); + return null; + } + + _ = int.TryParse(d.Groups["days"].Value, out var days); + _ = int.TryParse(d.Groups["hours"].Value, out var hours); + _ = int.TryParse(d.Groups["mins"].Value, out var mins); + if (days == 0 && hours == 0 && mins == 0) + { + if (react) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to parse `{duration}` as a time", true).ConfigureAwait(false); + return null; + } + + return new TimeSpan(days, hours, mins, 0); + } + + private static string FormatCountdown(TimeSpan timeSpan) + { + var result = ""; + var days = (int)timeSpan.TotalDays; + if (days > 0) + timeSpan -= TimeSpan.FromDays(days); + var hours = (int)timeSpan.TotalHours; + if (hours > 0) + timeSpan -= TimeSpan.FromHours(hours); + var mins = (int)timeSpan.TotalMinutes; + if (mins > 0) + timeSpan -= TimeSpan.FromMinutes(mins); + var secs = (int)timeSpan.TotalSeconds; + if (days > 0) + result += $"{days} day{(days == 1 ? "" : "s")} "; + if (hours > 0 || days > 0) + result += $"{hours} hour{(hours == 1 ? "" : "s")} "; + if (mins > 0 || hours > 0 || days > 0) + result += $"{mins} minute{(mins == 1 ? "" : "s")} "; + result += $"{secs} second{(secs == 1 ? "" : "s")}"; + return result; + } + + private static DiscordEmbedBuilder FormatEvent(EventSchedule evt, string? error = null, int highlight = -1) + { + var start = evt.Start.AsUtc(); + var field = 1; + var result = new DiscordEmbedBuilder + { + Title = "Schedule entry preview", + Color = string.IsNullOrEmpty(error) ? Config.Colors.Help : Config.Colors.Maintenance, + }; + if (!string.IsNullOrEmpty(error)) + result.AddField("Entry error", error); + var currentTime = DateTime.UtcNow; + if (evt.Start > currentTime.Ticks) + result.WithFooter($"Starts in {FormatCountdown(evt.Start.AsUtc() - currentTime)}"); + else if (evt.End > currentTime.Ticks) + result.WithFooter($"Ends in {FormatCountdown(evt.End.AsUtc() - currentTime)}"); + var eventDuration = evt.End.AsUtc() - start; + var durationFormat = eventDuration.TotalDays > 0 ? @"d\d\ h\h\ m\m" : @"h\h\ m\m"; + var startWarn = start < DateTime.UtcNow ? "⚠ " : ""; + result + .AddFieldEx(startWarn + "Start time", evt.Start == 0 ? "-" : start.ToString("u"), highlight == field++, true) + .AddFieldEx("Duration", evt.Start == evt.End ? "-" : eventDuration.ToString(durationFormat), highlight == field++, true) + .AddFieldEx("Event name", string.IsNullOrEmpty(evt.EventName) ? "-" : evt.EventName, highlight == field++, true) + .AddFieldEx("Schedule entry title", string.IsNullOrEmpty(evt.Name) ? "-" : evt.Name, highlight == field++, true); +#if DEBUG + result.WithFooter("Test bot instance"); +#endif + return result; + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Explain.cs b/CompatBot/Commands/Explain.cs index d2ee4757..32fe30d6 100644 --- a/CompatBot/Commands/Explain.cs +++ b/CompatBot/Commands/Explain.cs @@ -19,145 +19,96 @@ using DSharpPlus.Interactivity.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("explain"), Aliases("botsplain", "define")] +[Cooldown(1, 3, CooldownBucketType.Channel)] +[Description("Used to manage and show explanations")] +internal sealed class Explain: BaseCommandModuleCustom { - [Group("explain"), Aliases("botsplain", "define")] - [Cooldown(1, 3, CooldownBucketType.Channel)] - [Description("Used to manage and show explanations")] - internal sealed class Explain: BaseCommandModuleCustom + private const string TermListTitle = "Defined terms"; + + [GroupCommand] + public async Task ShowExplanation(CommandContext ctx, [RemainingText, Description("Term to explain")] string term) { - private const string TermListTitle = "Defined terms"; - - [GroupCommand] - public async Task ShowExplanation(CommandContext ctx, [RemainingText, Description("Term to explain")] string term) + if (string.IsNullOrEmpty(term)) { - if (string.IsNullOrEmpty(term)) - { - var lastBotMessages = await ctx.Channel.GetMessagesBeforeCachedAsync(ctx.Message.Id, 10).ConfigureAwait(false); - var showList = true; - foreach (var pastMsg in lastBotMessages) - if (pastMsg.Embeds.FirstOrDefault() is {Title: TermListTitle} - || BotReactionsHandler.NeedToSilence(pastMsg).needToChill) - { - showList = false; - break; - } - if (showList) - await List(ctx).ConfigureAwait(false); - var botMsg = await ctx.Channel.SendMessageAsync("Please tell what term to explain:").ConfigureAwait(false); - var interact = ctx.Client.GetInteractivity(); - var newMessage = await interact.WaitForMessageAsync(m => m.Author == ctx.User && m.Channel == ctx.Channel && !string.IsNullOrEmpty(m.Content)).ConfigureAwait(false); - await botMsg.DeleteAsync().ConfigureAwait(false); - if (string.IsNullOrEmpty(newMessage.Result?.Content) || newMessage.Result.Content.StartsWith(Config.CommandPrefix)) + var lastBotMessages = await ctx.Channel.GetMessagesBeforeCachedAsync(ctx.Message.Id, 10).ConfigureAwait(false); + var showList = true; + foreach (var pastMsg in lastBotMessages) + if (pastMsg.Embeds.FirstOrDefault() is {Title: TermListTitle} + || BotReactionsHandler.NeedToSilence(pastMsg).needToChill) { - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - return; + showList = false; + break; } - } - - if (!await DiscordInviteFilter.CheckMessageForInvitesAsync(ctx.Client, ctx.Message).ConfigureAwait(false)) + if (showList) + await List(ctx).ConfigureAwait(false); + var botMsg = await ctx.Channel.SendMessageAsync("Please tell what term to explain:").ConfigureAwait(false); + var interact = ctx.Client.GetInteractivity(); + var newMessage = await interact.WaitForMessageAsync(m => m.Author == ctx.User && m.Channel == ctx.Channel && !string.IsNullOrEmpty(m.Content)).ConfigureAwait(false); + await botMsg.DeleteAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(newMessage.Result?.Content) || newMessage.Result.Content.StartsWith(Config.CommandPrefix)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); return; - - if (!await ContentFilter.IsClean(ctx.Client, ctx.Message).ConfigureAwait(false)) - return; - - var hasMention = false; - term = term.ToLowerInvariant(); - var result = await LookupTerm(term).ConfigureAwait(false); - if (result.explanation == null || !string.IsNullOrEmpty(result.fuzzyMatch)) - { - term = term.StripQuotes(); - var idx = term.LastIndexOf(" to ", StringComparison.Ordinal); - if (idx > 0) - { - var potentialUserId = term[(idx + 4)..].Trim(); - try - { - var lookup = await ((IArgumentConverter)new DiscordUserConverter()).ConvertAsync(potentialUserId, ctx).ConfigureAwait(false); - hasMention = lookup.HasValue && lookup.Value.Id != ctx.Message.Author.Id; - } - catch {} - - if (hasMention) - { - term = term[..idx].TrimEnd(); - var mentionResult = await LookupTerm(term).ConfigureAwait(false); - if (mentionResult.score > result.score) - result = mentionResult; - } - } - } - - var needReply = !hasMention || ctx.Message.ReferencedMessage is not null; - if (await SendExplanation(result, term, ctx.Message.ReferencedMessage ?? ctx.Message, needReply).ConfigureAwait(false)) - return; - - string? inSpecificLocation = null; - if (!LimitedToSpamChannel.IsSpamChannel(ctx.Channel)) - { - var spamChannel = await ctx.Client.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); - inSpecificLocation = $" in {spamChannel.Mention} or bot DMs"; - } - var msg = $"Unknown term `{term.Sanitize(replaceBackTicks: true)}`. Use `{ctx.Prefix}explain list` to look at defined terms{inSpecificLocation}"; - await ctx.Channel.SendMessageAsync(msg).ConfigureAwait(false); - } - - [Command("add"), RequiresBotModRole] - [Description("Adds a new explanation to the list")] - public async Task Add(CommandContext ctx, - [Description("A term to explain. Quote it if it contains spaces")] string term, - [RemainingText, Description("Explanation text. Can have attachment")] string explanation) - { - try - { - term = term.ToLowerInvariant().StripQuotes(); - byte[]? attachment = null; - string? attachmentFilename = null; - if (ctx.Message.Attachments.FirstOrDefault() is DiscordAttachment att) - { - attachmentFilename = att.FileName; - try - { - using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); - attachment = await httpClient.GetByteArrayAsync(att.Url).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to download explanation attachment " + ctx); - } - } - - if (string.IsNullOrEmpty(explanation) && string.IsNullOrEmpty(attachmentFilename)) - await ctx.ReactWithAsync(Config.Reactions.Failure, "An explanation for the term must be provided").ConfigureAwait(false); - else - { - await using var db = new BotDb(); - if (await db.Explanation.AnyAsync(e => e.Keyword == term).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"`{term}` is already defined. Use `update` to update an existing term.").ConfigureAwait(false); - else - { - var entity = new Explanation - { - Keyword = term, Text = explanation, Attachment = attachment, - AttachmentFilename = attachmentFilename - }; - await db.Explanation.AddAsync(entity).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, $"`{term}` was successfully added").ConfigureAwait(false); - } - } - } - catch (Exception e) - { - Config.Log.Error(e, $"Failed to add explanation for `{term}`"); } } - [Command("update"), Aliases("replace"), RequiresBotModRole] - [Description("Update explanation for a given term")] - public async Task Update(CommandContext ctx, - [Description("A term to update. Quote it if it contains spaces")] string term, - [RemainingText, Description("New explanation text")] string explanation) + if (!await DiscordInviteFilter.CheckMessageForInvitesAsync(ctx.Client, ctx.Message).ConfigureAwait(false)) + return; + + if (!await ContentFilter.IsClean(ctx.Client, ctx.Message).ConfigureAwait(false)) + return; + + var hasMention = false; + term = term.ToLowerInvariant(); + var result = await LookupTerm(term).ConfigureAwait(false); + if (result.explanation == null || !string.IsNullOrEmpty(result.fuzzyMatch)) + { + term = term.StripQuotes(); + var idx = term.LastIndexOf(" to ", StringComparison.Ordinal); + if (idx > 0) + { + var potentialUserId = term[(idx + 4)..].Trim(); + try + { + var lookup = await ((IArgumentConverter)new DiscordUserConverter()).ConvertAsync(potentialUserId, ctx).ConfigureAwait(false); + hasMention = lookup.HasValue && lookup.Value.Id != ctx.Message.Author.Id; + } + catch {} + + if (hasMention) + { + term = term[..idx].TrimEnd(); + var mentionResult = await LookupTerm(term).ConfigureAwait(false); + if (mentionResult.score > result.score) + result = mentionResult; + } + } + } + + var needReply = !hasMention || ctx.Message.ReferencedMessage is not null; + if (await SendExplanation(result, term, ctx.Message.ReferencedMessage ?? ctx.Message, needReply).ConfigureAwait(false)) + return; + + string? inSpecificLocation = null; + if (!LimitedToSpamChannel.IsSpamChannel(ctx.Channel)) + { + var spamChannel = await ctx.Client.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); + inSpecificLocation = $" in {spamChannel.Mention} or bot DMs"; + } + var msg = $"Unknown term `{term.Sanitize(replaceBackTicks: true)}`. Use `{ctx.Prefix}explain list` to look at defined terms{inSpecificLocation}"; + await ctx.Channel.SendMessageAsync(msg).ConfigureAwait(false); + } + + [Command("add"), RequiresBotModRole] + [Description("Adds a new explanation to the list")] + public async Task Add(CommandContext ctx, + [Description("A term to explain. Quote it if it contains spaces")] string term, + [RemainingText, Description("Explanation text. Can have attachment")] string explanation) + { + try { term = term.ToLowerInvariant().StripQuotes(); byte[]? attachment = null; @@ -175,273 +126,321 @@ namespace CompatBot.Commands Config.Log.Warn(e, "Failed to download explanation attachment " + ctx); } } + + if (string.IsNullOrEmpty(explanation) && string.IsNullOrEmpty(attachmentFilename)) + await ctx.ReactWithAsync(Config.Reactions.Failure, "An explanation for the term must be provided").ConfigureAwait(false); + else + { + await using var db = new BotDb(); + if (await db.Explanation.AnyAsync(e => e.Keyword == term).ConfigureAwait(false)) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"`{term}` is already defined. Use `update` to update an existing term.").ConfigureAwait(false); + else + { + var entity = new Explanation + { + Keyword = term, Text = explanation, Attachment = attachment, + AttachmentFilename = attachmentFilename + }; + await db.Explanation.AddAsync(entity).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, $"`{term}` was successfully added").ConfigureAwait(false); + } + } + } + catch (Exception e) + { + Config.Log.Error(e, $"Failed to add explanation for `{term}`"); + } + } + + [Command("update"), Aliases("replace"), RequiresBotModRole] + [Description("Update explanation for a given term")] + public async Task Update(CommandContext ctx, + [Description("A term to update. Quote it if it contains spaces")] string term, + [RemainingText, Description("New explanation text")] string explanation) + { + term = term.ToLowerInvariant().StripQuotes(); + byte[]? attachment = null; + string? attachmentFilename = null; + if (ctx.Message.Attachments.FirstOrDefault() is DiscordAttachment att) + { + attachmentFilename = att.FileName; + try + { + using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); + attachment = await httpClient.GetByteArrayAsync(att.Url).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to download explanation attachment " + ctx); + } + } + await using var db = new BotDb(); + var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + if (item == null) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` is not defined").ConfigureAwait(false); + else + { + if (!string.IsNullOrEmpty(explanation)) + item.Text = explanation; + if (attachment?.Length > 0) + { + item.Attachment = attachment; + item.AttachmentFilename = attachmentFilename; + } + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, "Term was updated").ConfigureAwait(false); + } + } + + [Command("rename"), Priority(10), RequiresBotModRole] + public async Task Rename(CommandContext ctx, + [Description("A term to rename. Remember quotes if it contains spaces")] string oldTerm, + [Description("New term. Again, quotes")] string newTerm) + { + oldTerm = oldTerm.ToLowerInvariant().StripQuotes(); + newTerm = newTerm.ToLowerInvariant().StripQuotes(); + await using var db = new BotDb(); + var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == oldTerm).ConfigureAwait(false); + if (item == null) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{oldTerm}` is not defined").ConfigureAwait(false); + else if (await db.Explanation.AnyAsync(e => e.Keyword == newTerm).ConfigureAwait(false)) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{newTerm}` already defined, can't replace it with explanation for `{oldTerm}`").ConfigureAwait(false); + else + { + item.Keyword = newTerm; + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Renamed `{oldTerm}` to `{newTerm}`").ConfigureAwait(false); + } + } + + [Command("rename"), Priority(1), RequiresBotModRole] + [Description("Renames a term in case you misspelled it or something")] + public async Task Rename(CommandContext ctx, + [Description("A term to rename. Remember quotes if it contains spaces")] string oldTerm, + [Description("Constant \"to'")] string to, + [Description("New term. Again, quotes")] string newTerm) + { + if ("to".Equals(to, StringComparison.InvariantCultureIgnoreCase)) + await Rename(ctx, oldTerm, newTerm).ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + } + + [Command("list")] + [Description("List all known terms that could be used for !explain command")] + public async Task List(CommandContext ctx) + { + var responseChannel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); + await using var db = new BotDb(); + var keywords = await db.Explanation.Select(e => e.Keyword).OrderBy(t => t).ToListAsync().ConfigureAwait(false); + if (keywords.Count == 0) + await ctx.Channel.SendMessageAsync("Nothing has been defined yet").ConfigureAwait(false); + else + try + { + foreach (var embed in keywords.BreakInEmbeds(new DiscordEmbedBuilder {Title = TermListTitle, Color = Config.Colors.Help})) + await responseChannel.SendMessageAsync(embed: embed).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e); + } + } + + [Group("remove"), Aliases("delete", "del", "erase", "obliterate"), RequiresBotModRole] + [Description("Removes an explanation from the definition list")] + internal sealed class Remove: BaseCommandModuleCustom + { + [GroupCommand] + public async Task RemoveExplanation(CommandContext ctx, [RemainingText, Description("Term to remove")] string term) + { + term = term.ToLowerInvariant().StripQuotes(); + await using var db = new BotDb(); + var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + if (item is null) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` is not defined").ConfigureAwait(false); + else + { + db.Explanation.Remove(item); + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Removed `{term}`").ConfigureAwait(false); + } + } + + [Command("attachment"), Aliases("image", "picture", "file")] + [Description("Removes attachment from specified explanation. If there is no text, the whole explanation is removed")] + public async Task Attachment(CommandContext ctx, [RemainingText, Description("Term to remove")] string term) + { + term = term.ToLowerInvariant().StripQuotes(); + await using var db = new BotDb(); + var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + if (item is null) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` is not defined").ConfigureAwait(false); + else if (string.IsNullOrEmpty(item.AttachmentFilename)) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` doesn't have any attachments").ConfigureAwait(false); + else if (string.IsNullOrEmpty(item.Text)) + await RemoveExplanation(ctx, term).ConfigureAwait(false); + else + { + item.Attachment = null; + item.AttachmentFilename = null; + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Removed attachment for `{term}`").ConfigureAwait(false); + } + } + + [Command("text"), Aliases("description")] + [Description("Removes explanation text. If there is no attachment, the whole explanation is removed")] + public async Task Text(CommandContext ctx, [RemainingText, Description("Term to remove")] string term) + { + term = term.ToLowerInvariant().StripQuotes(); await using var db = new BotDb(); var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); if (item == null) await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` is not defined").ConfigureAwait(false); + else if (string.IsNullOrEmpty(item.Text)) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` doesn't have any text").ConfigureAwait(false); + else if (string.IsNullOrEmpty(item.AttachmentFilename)) + await RemoveExplanation(ctx, term).ConfigureAwait(false); else { - if (!string.IsNullOrEmpty(explanation)) - item.Text = explanation; - if (attachment?.Length > 0) - { - item.Attachment = attachment; - item.AttachmentFilename = attachmentFilename; - } + item.Text = ""; await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, "Term was updated").ConfigureAwait(false); - } - } - - [Command("rename"), Priority(10), RequiresBotModRole] - public async Task Rename(CommandContext ctx, - [Description("A term to rename. Remember quotes if it contains spaces")] string oldTerm, - [Description("New term. Again, quotes")] string newTerm) - { - oldTerm = oldTerm.ToLowerInvariant().StripQuotes(); - newTerm = newTerm.ToLowerInvariant().StripQuotes(); - await using var db = new BotDb(); - var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == oldTerm).ConfigureAwait(false); - if (item == null) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{oldTerm}` is not defined").ConfigureAwait(false); - else if (await db.Explanation.AnyAsync(e => e.Keyword == newTerm).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{newTerm}` already defined, can't replace it with explanation for `{oldTerm}`").ConfigureAwait(false); - else - { - item.Keyword = newTerm; - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Renamed `{oldTerm}` to `{newTerm}`").ConfigureAwait(false); - } - } - - [Command("rename"), Priority(1), RequiresBotModRole] - [Description("Renames a term in case you misspelled it or something")] - public async Task Rename(CommandContext ctx, - [Description("A term to rename. Remember quotes if it contains spaces")] string oldTerm, - [Description("Constant \"to'")] string to, - [Description("New term. Again, quotes")] string newTerm) - { - if ("to".Equals(to, StringComparison.InvariantCultureIgnoreCase)) - await Rename(ctx, oldTerm, newTerm).ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - } - - [Command("list")] - [Description("List all known terms that could be used for !explain command")] - public async Task List(CommandContext ctx) - { - var responseChannel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); - await using var db = new BotDb(); - var keywords = await db.Explanation.Select(e => e.Keyword).OrderBy(t => t).ToListAsync().ConfigureAwait(false); - if (keywords.Count == 0) - await ctx.Channel.SendMessageAsync("Nothing has been defined yet").ConfigureAwait(false); - else - try - { - foreach (var embed in keywords.BreakInEmbeds(new DiscordEmbedBuilder {Title = TermListTitle, Color = Config.Colors.Help})) - await responseChannel.SendMessageAsync(embed: embed).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - } - } - - [Group("remove"), Aliases("delete", "del", "erase", "obliterate"), RequiresBotModRole] - [Description("Removes an explanation from the definition list")] - internal sealed class Remove: BaseCommandModuleCustom - { - [GroupCommand] - public async Task RemoveExplanation(CommandContext ctx, [RemainingText, Description("Term to remove")] string term) - { - term = term.ToLowerInvariant().StripQuotes(); - await using var db = new BotDb(); - var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); - if (item is null) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` is not defined").ConfigureAwait(false); - else - { - db.Explanation.Remove(item); - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Removed `{term}`").ConfigureAwait(false); - } - } - - [Command("attachment"), Aliases("image", "picture", "file")] - [Description("Removes attachment from specified explanation. If there is no text, the whole explanation is removed")] - public async Task Attachment(CommandContext ctx, [RemainingText, Description("Term to remove")] string term) - { - term = term.ToLowerInvariant().StripQuotes(); - await using var db = new BotDb(); - var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); - if (item is null) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` is not defined").ConfigureAwait(false); - else if (string.IsNullOrEmpty(item.AttachmentFilename)) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` doesn't have any attachments").ConfigureAwait(false); - else if (string.IsNullOrEmpty(item.Text)) - await RemoveExplanation(ctx, term).ConfigureAwait(false); - else - { - item.Attachment = null; - item.AttachmentFilename = null; - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Removed attachment for `{term}`").ConfigureAwait(false); - } - } - - [Command("text"), Aliases("description")] - [Description("Removes explanation text. If there is no attachment, the whole explanation is removed")] - public async Task Text(CommandContext ctx, [RemainingText, Description("Term to remove")] string term) - { - term = term.ToLowerInvariant().StripQuotes(); - await using var db = new BotDb(); - var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); - if (item == null) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` is not defined").ConfigureAwait(false); - else if (string.IsNullOrEmpty(item.Text)) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Term `{term}` doesn't have any text").ConfigureAwait(false); - else if (string.IsNullOrEmpty(item.AttachmentFilename)) - await RemoveExplanation(ctx, term).ConfigureAwait(false); - else - { - item.Text = ""; - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Removed explanation text for `{term}`").ConfigureAwait(false); - } - } - } - - [Command("dump"), Aliases("download")] - [Description("Returns explanation text as a file attachment")] - public async Task Dump(CommandContext ctx, [RemainingText, Description("Term to dump **or** a link to a message containing the explanation")] string? termOrLink = null) - { - if (string.IsNullOrEmpty(termOrLink)) - { - var term = ctx.Message.Content.Split(' ', 2).Last(); - await ShowExplanation(ctx, term).ConfigureAwait(false); - return; - } - - if (!await DiscordInviteFilter.CheckMessageForInvitesAsync(ctx.Client, ctx.Message).ConfigureAwait(false)) - return; - - termOrLink = termOrLink.ToLowerInvariant().StripQuotes(); - var isLink = CommandContextExtensions.MessageLinkRegex.IsMatch(termOrLink); - if (isLink) - { - await DumpLink(ctx, termOrLink).ConfigureAwait(false); - return; - } - - await using var db = new BotDb(); - var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == termOrLink).ConfigureAwait(false); - if (item is null) - { - var term = ctx.Message.Content.Split(' ', 2).Last(); - await ShowExplanation(ctx, term).ConfigureAwait(false); - } - else - { - if (!string.IsNullOrEmpty(item.Text)) - { - await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(item.Text)); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile($"{termOrLink}.txt", stream)).ConfigureAwait(false); - } - if (!string.IsNullOrEmpty(item.AttachmentFilename) && item.Attachment?.Length > 0) - { - await using var stream = new MemoryStream(item.Attachment); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile(item.AttachmentFilename, stream)).ConfigureAwait(false); - } - } - } - - internal static async Task<(Explanation? explanation, string? fuzzyMatch, double score)> LookupTerm(string term) - { - await using var db = new BotDb(); - string? fuzzyMatch = null; - double coefficient; - var explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); - if (explanation == null) - { - var termList = await db.Explanation.Select(e => e.Keyword).ToListAsync().ConfigureAwait(false); - var bestSuggestion = termList.OrderByDescending(term.GetFuzzyCoefficientCached).First(); - coefficient = term.GetFuzzyCoefficientCached(bestSuggestion); - explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == bestSuggestion).ConfigureAwait(false); - fuzzyMatch = bestSuggestion; - } - else - coefficient = 2.0; - return (explanation, fuzzyMatch, coefficient); - } - - internal static async Task SendExplanation((Explanation? explanation, string? fuzzyMatch, double score) termLookupResult, string term, DiscordMessage sourceMessage, bool useReply) - { - try - { - if (termLookupResult.explanation != null && termLookupResult.score > 0.5) - { - var usedReply = false; - DiscordMessageBuilder msgBuilder; - if (!string.IsNullOrEmpty(termLookupResult.fuzzyMatch)) - { - var fuzzyNotice = $"Showing explanation for `{termLookupResult.fuzzyMatch}`:"; -#if DEBUG - fuzzyNotice = $"Showing explanation for `{termLookupResult.fuzzyMatch}` ({termLookupResult.score:0.######}):"; -#endif - msgBuilder = new DiscordMessageBuilder().WithContent(fuzzyNotice); - if (useReply) - msgBuilder.WithReply(sourceMessage.Id); - await sourceMessage.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); - usedReply = true; - } - - var explain = termLookupResult.explanation; - StatsStorage.ExplainStatCache.TryGetValue(explain.Keyword, out int stat); - StatsStorage.ExplainStatCache.Set(explain.Keyword, ++stat, StatsStorage.CacheTime); - msgBuilder = new DiscordMessageBuilder().WithContent(explain.Text); - if (!usedReply && useReply) - msgBuilder.WithReply(sourceMessage.Id); - if (explain.Attachment is {Length: >0}) - { - await using var memStream = Config.MemoryStreamManager.GetStream(explain.Attachment); - memStream.Seek(0, SeekOrigin.Begin); - msgBuilder.WithFile(explain.AttachmentFilename, memStream); - await sourceMessage.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); - } - else - await sourceMessage.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); - return true; - } - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to explain " + term); - return true; - } - return false; - } - - private static async Task DumpLink(CommandContext ctx, string messageLink) - { - string? explanation = null; - DiscordMessage? msg = null; - try { msg = await ctx.GetMessageAsync(messageLink).ConfigureAwait(false); } catch {} - if (msg != null) - { - if (msg.Embeds.FirstOrDefault() is DiscordEmbed embed && !string.IsNullOrEmpty(embed.Description)) - explanation = embed.Description; - else if (!string.IsNullOrEmpty(msg.Content)) - explanation = msg.Content; - } - - if (string.IsNullOrEmpty(explanation)) - await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't find any text in the specified message").ConfigureAwait(false); - else - { - await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(explanation)); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("explanation.txt", stream)).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Removed explanation text for `{term}`").ConfigureAwait(false); } } } -} + + [Command("dump"), Aliases("download")] + [Description("Returns explanation text as a file attachment")] + public async Task Dump(CommandContext ctx, [RemainingText, Description("Term to dump **or** a link to a message containing the explanation")] string? termOrLink = null) + { + if (string.IsNullOrEmpty(termOrLink)) + { + var term = ctx.Message.Content.Split(' ', 2).Last(); + await ShowExplanation(ctx, term).ConfigureAwait(false); + return; + } + + if (!await DiscordInviteFilter.CheckMessageForInvitesAsync(ctx.Client, ctx.Message).ConfigureAwait(false)) + return; + + termOrLink = termOrLink.ToLowerInvariant().StripQuotes(); + var isLink = CommandContextExtensions.MessageLinkRegex.IsMatch(termOrLink); + if (isLink) + { + await DumpLink(ctx, termOrLink).ConfigureAwait(false); + return; + } + + await using var db = new BotDb(); + var item = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == termOrLink).ConfigureAwait(false); + if (item is null) + { + var term = ctx.Message.Content.Split(' ', 2).Last(); + await ShowExplanation(ctx, term).ConfigureAwait(false); + } + else + { + if (!string.IsNullOrEmpty(item.Text)) + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(item.Text)); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile($"{termOrLink}.txt", stream)).ConfigureAwait(false); + } + if (!string.IsNullOrEmpty(item.AttachmentFilename) && item.Attachment?.Length > 0) + { + await using var stream = new MemoryStream(item.Attachment); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile(item.AttachmentFilename, stream)).ConfigureAwait(false); + } + } + } + + internal static async Task<(Explanation? explanation, string? fuzzyMatch, double score)> LookupTerm(string term) + { + await using var db = new BotDb(); + string? fuzzyMatch = null; + double coefficient; + var explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + if (explanation == null) + { + var termList = await db.Explanation.Select(e => e.Keyword).ToListAsync().ConfigureAwait(false); + var bestSuggestion = termList.OrderByDescending(term.GetFuzzyCoefficientCached).First(); + coefficient = term.GetFuzzyCoefficientCached(bestSuggestion); + explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == bestSuggestion).ConfigureAwait(false); + fuzzyMatch = bestSuggestion; + } + else + coefficient = 2.0; + return (explanation, fuzzyMatch, coefficient); + } + + internal static async Task SendExplanation((Explanation? explanation, string? fuzzyMatch, double score) termLookupResult, string term, DiscordMessage sourceMessage, bool useReply) + { + try + { + if (termLookupResult.explanation != null && termLookupResult.score > 0.5) + { + var usedReply = false; + DiscordMessageBuilder msgBuilder; + if (!string.IsNullOrEmpty(termLookupResult.fuzzyMatch)) + { + var fuzzyNotice = $"Showing explanation for `{termLookupResult.fuzzyMatch}`:"; +#if DEBUG + fuzzyNotice = $"Showing explanation for `{termLookupResult.fuzzyMatch}` ({termLookupResult.score:0.######}):"; +#endif + msgBuilder = new DiscordMessageBuilder().WithContent(fuzzyNotice); + if (useReply) + msgBuilder.WithReply(sourceMessage.Id); + await sourceMessage.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + usedReply = true; + } + + var explain = termLookupResult.explanation; + StatsStorage.ExplainStatCache.TryGetValue(explain.Keyword, out int stat); + StatsStorage.ExplainStatCache.Set(explain.Keyword, ++stat, StatsStorage.CacheTime); + msgBuilder = new DiscordMessageBuilder().WithContent(explain.Text); + if (!usedReply && useReply) + msgBuilder.WithReply(sourceMessage.Id); + if (explain.Attachment is {Length: >0}) + { + await using var memStream = Config.MemoryStreamManager.GetStream(explain.Attachment); + memStream.Seek(0, SeekOrigin.Begin); + msgBuilder.WithFile(explain.AttachmentFilename, memStream); + await sourceMessage.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + } + else + await sourceMessage.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + return true; + } + } + catch (Exception e) + { + Config.Log.Error(e, "Failed to explain " + term); + return true; + } + return false; + } + + private static async Task DumpLink(CommandContext ctx, string messageLink) + { + string? explanation = null; + DiscordMessage? msg = null; + try { msg = await ctx.GetMessageAsync(messageLink).ConfigureAwait(false); } catch {} + if (msg != null) + { + if (msg.Embeds.FirstOrDefault() is DiscordEmbed embed && !string.IsNullOrEmpty(embed.Description)) + explanation = embed.Description; + else if (!string.IsNullOrEmpty(msg.Content)) + explanation = msg.Content; + } + + if (string.IsNullOrEmpty(explanation)) + await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't find any text in the specified message").ConfigureAwait(false); + else + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(explanation)); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("explanation.txt", stream)).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/CompatBot/Commands/ForcedNicknames.cs b/CompatBot/Commands/ForcedNicknames.cs index 9d3eb7b0..f200078f 100644 --- a/CompatBot/Commands/ForcedNicknames.cs +++ b/CompatBot/Commands/ForcedNicknames.cs @@ -13,243 +13,242 @@ using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("rename")] +[Description("Manage users who has forced nickname.")] +internal sealed class ForcedNicknames : BaseCommandModuleCustom { - [Group("rename")] - [Description("Manage users who has forced nickname.")] - internal sealed class ForcedNicknames : BaseCommandModuleCustom + [GroupCommand] + [Description("Enforces specific nickname for particular user.")] + public async Task Rename(CommandContext ctx, + [Description("Discord user to add to forced nickname list.")] DiscordUser discordUser, + [Description("Nickname which should be displayed."), RemainingText] string expectedNickname) { - [GroupCommand] - [Description("Enforces specific nickname for particular user.")] - public async Task Rename(CommandContext ctx, - [Description("Discord user to add to forced nickname list.")] DiscordUser discordUser, - [Description("Nickname which should be displayed."), RemainingText] string expectedNickname) - { - if (!await new RequiresBotModRole().ExecuteCheckAsync(ctx, false).ConfigureAwait(false)) - return; + if (!await new RequiresBotModRole().ExecuteCheckAsync(ctx, false).ConfigureAwait(false)) + return; - try + try + { + if (expectedNickname.Length is < 2 or > 32) { - if (expectedNickname.Length is < 2 or > 32) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Nickname must be between 2 and 32 characters long", true).ConfigureAwait(false); - return; - } + await ctx.ReactWithAsync(Config.Reactions.Failure, "Nickname must be between 2 and 32 characters long", true).ConfigureAwait(false); + return; + } - if ((!expectedNickname.All(c => char.IsLetterOrDigit(c) - || char.IsWhiteSpace(c) - || char.IsPunctuation(c)) - || expectedNickname.Any(c => c is ':' or '#' or '@' or '`') - ) && !discordUser.IsBotSafeCheck()) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Nickname must follow Rule 7", true).ConfigureAwait(false); - return; - } + if ((!expectedNickname.All(c => char.IsLetterOrDigit(c) + || char.IsWhiteSpace(c) + || char.IsPunctuation(c)) + || expectedNickname.Any(c => c is ':' or '#' or '@' or '`') + ) && !discordUser.IsBotSafeCheck()) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Nickname must follow Rule 7", true).ConfigureAwait(false); + return; + } - List guilds; - if (ctx.Guild == null) - { - guilds = ctx.Client.Guilds?.Values.ToList() ?? new List(0); - if (guilds.Count > 1) - await ctx.Channel.SendMessageAsync($"{discordUser.Mention} will be renamed in all {guilds.Count} servers").ConfigureAwait(false); - } - else - guilds = new(){ctx.Guild}; - - int changed = 0, noPermissions = 0, failed = 0; - await using var db = new BotDb(); - foreach (var guild in guilds) - { - if (!discordUser.IsBotSafeCheck()) - { - var enforceRules = db.ForcedNicknames.FirstOrDefault(mem => mem.UserId == discordUser.Id && mem.GuildId == guild.Id); - if (enforceRules is null) - { - enforceRules = new() {UserId = discordUser.Id, GuildId = guild.Id, Nickname = expectedNickname}; - await db.ForcedNicknames.AddAsync(enforceRules).ConfigureAwait(false); - } - else - { - if (enforceRules.Nickname == expectedNickname) - { - continue; - } - enforceRules.Nickname = expectedNickname; - } - } - if (!(ctx.Guild?.Permissions?.HasFlag(Permissions.ChangeNickname) ?? true)) - { - noPermissions++; - continue; - } - - if (ctx.Client.GetMember(guild, discordUser) is DiscordMember discordMember) - try - { - await discordMember.ModifyAsync(x => x.Nickname = expectedNickname).ConfigureAwait(false); - changed++; - } - catch (Exception ex) - { - Config.Log.Warn(ex, "Failed to change nickname"); - failed++; - } - } - await db.SaveChangesAsync().ConfigureAwait(false); + List guilds; + if (ctx.Guild == null) + { + guilds = ctx.Client.Guilds?.Values.ToList() ?? new List(0); if (guilds.Count > 1) - { - if (changed > 0) - await ctx.ReactWithAsync(Config.Reactions.Success, $"Forced nickname for {discordUser.Mention} in {changed} server{(changed == 1 ? "" : "s")}", true).ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to force nickname for {discordUser.Mention} in any server").ConfigureAwait(false); - } - else - { - if (changed > 0) - await ctx.ReactWithAsync(Config.Reactions.Success, $"Forced nickname for {discordUser.Mention}", true).ConfigureAwait(false); - else if (failed > 0) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to force nickname for {discordUser.Mention}").ConfigureAwait(false); - else if (noPermissions > 0) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"No permissions to force nickname for {discordUser.Mention}").ConfigureAwait(false); - } + await ctx.Channel.SendMessageAsync($"{discordUser.Mention} will be renamed in all {guilds.Count} servers").ConfigureAwait(false); } - catch (Exception e) - { - Config.Log.Error(e); - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to change nickname, check bot's permissions").ConfigureAwait(false); - } - } + else + guilds = new(){ctx.Guild}; - [Command("clear"), Aliases("remove"), RequiresBotModRole] - [Description("Removes nickname restriction from particular user.")] - public async Task Remove(CommandContext ctx, [Description("Discord user to remove from forced nickname list.")] DiscordUser discordUser) - { - try + int changed = 0, noPermissions = 0, failed = 0; + await using var db = new BotDb(); + foreach (var guild in guilds) { - if (discordUser.IsBotSafeCheck()) + if (!discordUser.IsBotSafeCheck()) { - var mem = ctx.Client.GetMember(ctx.Guild.Id, discordUser); - if (mem is not null) + var enforceRules = db.ForcedNicknames.FirstOrDefault(mem => mem.UserId == discordUser.Id && mem.GuildId == guild.Id); + if (enforceRules is null) { - await mem.ModifyAsync(m => m.Nickname = new(discordUser.Username)).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + enforceRules = new() {UserId = discordUser.Id, GuildId = guild.Id, Nickname = expectedNickname}; + await db.ForcedNicknames.AddAsync(enforceRules).ConfigureAwait(false); + } + else + { + if (enforceRules.Nickname == expectedNickname) + { + continue; + } + enforceRules.Nickname = expectedNickname; } - return; } - - await using var db = new BotDb(); - var enforcedRules = ctx.Guild == null - ? await db.ForcedNicknames.Where(mem => mem.UserId == discordUser.Id).ToListAsync().ConfigureAwait(false) - : await db.ForcedNicknames.Where(mem => mem.UserId == discordUser.Id && mem.GuildId == ctx.Guild.Id).ToListAsync().ConfigureAwait(false); - if (enforcedRules.Count == 0) - return; - - db.ForcedNicknames.RemoveRange(enforcedRules); - await db.SaveChangesAsync().ConfigureAwait(false); - foreach (var rule in enforcedRules) - if (ctx.Client.GetMember(rule.GuildId, discordUser) is DiscordMember discordMember) - try - { - //todo: change to mem.Nickname = default when the library fixes their shit - await discordMember.ModifyAsync(mem => mem.Nickname = new(discordMember.Username)).ConfigureAwait(false); - } - catch (Exception ex) - { - Config.Log.Debug(ex); - } - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to reset user nickname").ConfigureAwait(false); - } - } + if (!(ctx.Guild?.Permissions?.HasFlag(Permissions.ChangeNickname) ?? true)) + { + noPermissions++; + continue; + } - [Command("cleanup"), Aliases("clean", "fix"), RequiresBotModRole] - [Description("Removes zalgo from specified user nickname")] - public async Task Cleanup(CommandContext ctx, [Description("Discord user to clean up")] DiscordMember discordUser) - { - var name = discordUser.DisplayName; - var newName = UsernameZalgoMonitor.StripZalgo(name, discordUser.Username, discordUser.Id); - if (name == newName) - await ctx.Channel.SendMessageAsync("Failed to remove any extra symbols").ConfigureAwait(false); + if (ctx.Client.GetMember(guild, discordUser) is DiscordMember discordMember) + try + { + await discordMember.ModifyAsync(x => x.Nickname = expectedNickname).ConfigureAwait(false); + changed++; + } + catch (Exception ex) + { + Config.Log.Warn(ex, "Failed to change nickname"); + failed++; + } + } + await db.SaveChangesAsync().ConfigureAwait(false); + if (guilds.Count > 1) + { + if (changed > 0) + await ctx.ReactWithAsync(Config.Reactions.Success, $"Forced nickname for {discordUser.Mention} in {changed} server{(changed == 1 ? "" : "s")}", true).ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to force nickname for {discordUser.Mention} in any server").ConfigureAwait(false); + } else { - try - { - await discordUser.ModifyAsync(m => m.Nickname = new(newName)).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Renamed user to {newName}", true).ConfigureAwait(false); - } - catch (Exception) - { - Config.Log.Warn($"Failed to rename user {discordUser.Username}#{discordUser.Discriminator}"); - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to rename user to {newName}").ConfigureAwait(false); - } + if (changed > 0) + await ctx.ReactWithAsync(Config.Reactions.Success, $"Forced nickname for {discordUser.Mention}", true).ConfigureAwait(false); + else if (failed > 0) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to force nickname for {discordUser.Mention}").ConfigureAwait(false); + else if (noPermissions > 0) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"No permissions to force nickname for {discordUser.Mention}").ConfigureAwait(false); } } - - [Command("dump")] - [Description("Prints hexadecimal binary representation of an UTF-8 encoded user name for diagnostic purposes")] - public async Task Dump(CommandContext ctx, [Description("Discord user to dump")] DiscordUser discordUser) + catch (Exception e) { - var name = discordUser.Username; - var nameBytes = StringUtils.Utf8.GetBytes(name); - var hex = BitConverter.ToString(nameBytes).Replace('-', ' '); - var result = $"User ID: {discordUser.Id}\nUsername: {hex}"; - var member = ctx.Client.GetMember(ctx.Guild, discordUser); - if (member?.Nickname is string {Length: >0} nickname) - { - nameBytes = StringUtils.Utf8.GetBytes(nickname); - hex = BitConverter.ToString(nameBytes).Replace('-', ' '); - result += "\nNickname: " + hex; - } - await ctx.Channel.SendMessageAsync(result).ConfigureAwait(false); + Config.Log.Error(e); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to change nickname, check bot's permissions").ConfigureAwait(false); } + } - [Command("generate"), Aliases("gen", "suggest")] - [Description("Generates random name for specified user")] - public async Task Generate(CommandContext ctx, [Description("Discord user to dump")] DiscordUser discordUser) + [Command("clear"), Aliases("remove"), RequiresBotModRole] + [Description("Removes nickname restriction from particular user.")] + public async Task Remove(CommandContext ctx, [Description("Discord user to remove from forced nickname list.")] DiscordUser discordUser) + { + try { - var newName = UsernameZalgoMonitor.GenerateRandomName(discordUser.Id); - await ctx.Channel.SendMessageAsync(newName).ConfigureAwait(false); - } - - [Command("list"), RequiresBotModRole] - [Description("Lists all users who has restricted nickname.")] - public async Task List(CommandContext ctx) - { - await using var db = new BotDb(); - var selectExpr = db.ForcedNicknames.AsNoTracking(); - if (ctx.Guild != null) - selectExpr = selectExpr.Where(mem => mem.GuildId == ctx.Guild.Id); - - var forcedNicknames = ( - from m in selectExpr.AsEnumerable() - orderby m.UserId, m.Nickname - let result = new {m.UserId, m.Nickname} - select result - ).ToList(); - if (forcedNicknames.Count == 0) + if (discordUser.IsBotSafeCheck()) { - await ctx.Channel.SendMessageAsync("No users with forced nicknames").ConfigureAwait(false); + var mem = ctx.Client.GetMember(ctx.Guild.Id, discordUser); + if (mem is not null) + { + await mem.ModifyAsync(m => m.Nickname = new(discordUser.Username)).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + } return; } - - var table = new AsciiTable( - new AsciiColumn("ID", !ctx.Channel.IsPrivate || !ctx.User.IsWhitelisted(ctx.Client)), - new AsciiColumn("Username"), - new AsciiColumn("Forced nickname") - ); - var previousUser = 0ul; - foreach (var forcedNickname in forcedNicknames.Distinct()) - { - var sameUser = forcedNickname.UserId == previousUser; - var username = sameUser ? "" : await ctx.GetUserNameAsync(forcedNickname.UserId).ConfigureAwait(false); - table.Add( sameUser ? "" : forcedNickname.UserId.ToString(), username, forcedNickname.Nickname); - previousUser = forcedNickname.UserId; - } - await ctx.SendAutosplitMessageAsync(table.ToString()).ConfigureAwait(false); - } + + await using var db = new BotDb(); + var enforcedRules = ctx.Guild == null + ? await db.ForcedNicknames.Where(mem => mem.UserId == discordUser.Id).ToListAsync().ConfigureAwait(false) + : await db.ForcedNicknames.Where(mem => mem.UserId == discordUser.Id && mem.GuildId == ctx.Guild.Id).ToListAsync().ConfigureAwait(false); + if (enforcedRules.Count == 0) + return; + + db.ForcedNicknames.RemoveRange(enforcedRules); + await db.SaveChangesAsync().ConfigureAwait(false); + foreach (var rule in enforcedRules) + if (ctx.Client.GetMember(rule.GuildId, discordUser) is DiscordMember discordMember) + try + { + //todo: change to mem.Nickname = default when the library fixes their shit + await discordMember.ModifyAsync(mem => mem.Nickname = new(discordMember.Username)).ConfigureAwait(false); + } + catch (Exception ex) + { + Config.Log.Debug(ex); + } + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to reset user nickname").ConfigureAwait(false); + } } + + [Command("cleanup"), Aliases("clean", "fix"), RequiresBotModRole] + [Description("Removes zalgo from specified user nickname")] + public async Task Cleanup(CommandContext ctx, [Description("Discord user to clean up")] DiscordMember discordUser) + { + var name = discordUser.DisplayName; + var newName = UsernameZalgoMonitor.StripZalgo(name, discordUser.Username, discordUser.Id); + if (name == newName) + await ctx.Channel.SendMessageAsync("Failed to remove any extra symbols").ConfigureAwait(false); + else + { + try + { + await discordUser.ModifyAsync(m => m.Nickname = new(newName)).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Renamed user to {newName}", true).ConfigureAwait(false); + } + catch (Exception) + { + Config.Log.Warn($"Failed to rename user {discordUser.Username}#{discordUser.Discriminator}"); + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to rename user to {newName}").ConfigureAwait(false); + } + } + } + + [Command("dump")] + [Description("Prints hexadecimal binary representation of an UTF-8 encoded user name for diagnostic purposes")] + public async Task Dump(CommandContext ctx, [Description("Discord user to dump")] DiscordUser discordUser) + { + var name = discordUser.Username; + var nameBytes = StringUtils.Utf8.GetBytes(name); + var hex = BitConverter.ToString(nameBytes).Replace('-', ' '); + var result = $"User ID: {discordUser.Id}\nUsername: {hex}"; + var member = ctx.Client.GetMember(ctx.Guild, discordUser); + if (member?.Nickname is string {Length: >0} nickname) + { + nameBytes = StringUtils.Utf8.GetBytes(nickname); + hex = BitConverter.ToString(nameBytes).Replace('-', ' '); + result += "\nNickname: " + hex; + } + await ctx.Channel.SendMessageAsync(result).ConfigureAwait(false); + } + + [Command("generate"), Aliases("gen", "suggest")] + [Description("Generates random name for specified user")] + public async Task Generate(CommandContext ctx, [Description("Discord user to dump")] DiscordUser discordUser) + { + var newName = UsernameZalgoMonitor.GenerateRandomName(discordUser.Id); + await ctx.Channel.SendMessageAsync(newName).ConfigureAwait(false); + } + + [Command("list"), RequiresBotModRole] + [Description("Lists all users who has restricted nickname.")] + public async Task List(CommandContext ctx) + { + await using var db = new BotDb(); + var selectExpr = db.ForcedNicknames.AsNoTracking(); + if (ctx.Guild != null) + selectExpr = selectExpr.Where(mem => mem.GuildId == ctx.Guild.Id); + + var forcedNicknames = ( + from m in selectExpr.AsEnumerable() + orderby m.UserId, m.Nickname + let result = new {m.UserId, m.Nickname} + select result + ).ToList(); + if (forcedNicknames.Count == 0) + { + await ctx.Channel.SendMessageAsync("No users with forced nicknames").ConfigureAwait(false); + return; + } + + var table = new AsciiTable( + new AsciiColumn("ID", !ctx.Channel.IsPrivate || !ctx.User.IsWhitelisted(ctx.Client)), + new AsciiColumn("Username"), + new AsciiColumn("Forced nickname") + ); + var previousUser = 0ul; + foreach (var forcedNickname in forcedNicknames.Distinct()) + { + var sameUser = forcedNickname.UserId == previousUser; + var username = sameUser ? "" : await ctx.GetUserNameAsync(forcedNickname.UserId).ConfigureAwait(false); + table.Add( sameUser ? "" : forcedNickname.UserId.ToString(), username, forcedNickname.Nickname); + previousUser = forcedNickname.UserId; + } + await ctx.SendAutosplitMessageAsync(table.ToString()).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/CompatBot/Commands/Fortune.cs b/CompatBot/Commands/Fortune.cs index a92a0419..330b20d3 100644 --- a/CompatBot/Commands/Fortune.cs +++ b/CompatBot/Commands/Fortune.cs @@ -15,247 +15,246 @@ using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("fortune"), Aliases("fortunes")] +[Description("Gives you a fortune once a day")] +internal sealed class Fortune : BaseCommandModuleCustom { - [Group("fortune"), Aliases("fortunes")] - [Description("Gives you a fortune once a day")] - internal sealed class Fortune : BaseCommandModuleCustom + private static readonly SemaphoreSlim ImportCheck = new(1, 1); + + [GroupCommand] + public Task ShowFortune(CommandContext ctx) + => ShowFortune(ctx.Message, ctx.User); + + public static async Task ShowFortune(DiscordMessage message, DiscordUser user) { - private static readonly SemaphoreSlim ImportCheck = new(1, 1); - - [GroupCommand] - public Task ShowFortune(CommandContext ctx) - => ShowFortune(ctx.Message, ctx.User); - - public static async Task ShowFortune(DiscordMessage message, DiscordUser user) + var prefix = DateTime.UtcNow.ToString("yyyyMMdd")+ user.Id.ToString("x16"); + var rng = new Random(prefix.GetStableHash()); + await using var db = new ThumbnailDb(); + Database.Fortune? fortune; + do { - var prefix = DateTime.UtcNow.ToString("yyyyMMdd")+ user.Id.ToString("x16"); - var rng = new Random(prefix.GetStableHash()); - await using var db = new ThumbnailDb(); - Database.Fortune? fortune; - do + var totalFortunes = await db.Fortune.CountAsync().ConfigureAwait(false); + if (totalFortunes == 0) { - var totalFortunes = await db.Fortune.CountAsync().ConfigureAwait(false); - if (totalFortunes == 0) - { - await message.ReactWithAsync(Config.Reactions.Failure, "There are no fortunes to tell", true).ConfigureAwait(false); - return; - } + await message.ReactWithAsync(Config.Reactions.Failure, "There are no fortunes to tell", true).ConfigureAwait(false); + return; + } - var selectedId = rng.Next(totalFortunes); - fortune = await db.Fortune.AsNoTracking().Skip(selectedId).FirstOrDefaultAsync().ConfigureAwait(false); - } while (fortune is null); + var selectedId = rng.Next(totalFortunes); + fortune = await db.Fortune.AsNoTracking().Skip(selectedId).FirstOrDefaultAsync().ConfigureAwait(false); + } while (fortune is null); - var msg = fortune.Content.FixTypography(); - var msgParts = msg.Split('\n'); - var tmp = new StringBuilder(); - var quote = true; - foreach (var l in msgParts) - { - quote &= !l.StartsWith(" "); - if (quote) - tmp.Append("> "); - tmp.Append(l).Append('\n'); - } - msg = tmp.ToString().TrimEnd().FixSpaces(); - var msgBuilder = new DiscordMessageBuilder() - .WithContent($"{user.Mention}, your fortune for today:\n{msg}") - .WithReply(message.Id); - await message.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); - } - - [Command("add"), RequiresBotModRole] - [Description("Add a new fortune")] - public async Task Add(CommandContext ctx, [RemainingText] string text) + var msg = fortune.Content.FixTypography(); + var msgParts = msg.Split('\n'); + var tmp = new StringBuilder(); + var quote = true; + foreach (var l in msgParts) { - text = text.Replace("\r\n", "\n").Trim(); - if (text.Length > 1800) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Fortune text is too long", true).ConfigureAwait(false); - return; - } + quote &= !l.StartsWith(" "); + if (quote) + tmp.Append("> "); + tmp.Append(l).Append('\n'); + } + msg = tmp.ToString().TrimEnd().FixSpaces(); + var msgBuilder = new DiscordMessageBuilder() + .WithContent($"{user.Mention}, your fortune for today:\n{msg}") + .WithReply(message.Id); + await message.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + } + + [Command("add"), RequiresBotModRole] + [Description("Add a new fortune")] + public async Task Add(CommandContext ctx, [RemainingText] string text) + { + text = text.Replace("\r\n", "\n").Trim(); + if (text.Length > 1800) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Fortune text is too long", true).ConfigureAwait(false); + return; + } - await using var db = new ThumbnailDb(); - await db.Fortune.AddAsync(new() {Content = text}).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + await using var db = new ThumbnailDb(); + await db.Fortune.AddAsync(new() {Content = text}).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + } + + [Command("remove"), Aliases("delete"), RequiresBotModRole] + [Description("Removes fortune with specified ID")] + public async Task Remove(CommandContext ctx, int id) + { + await using var db = new ThumbnailDb(); + var fortune = await db.Fortune.FirstOrDefaultAsync(f => f.Id == id).ConfigureAwait(false); + if (fortune is null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Fortune with id {id} wasn't found", true).ConfigureAwait(false); + return; } - [Command("remove"), Aliases("delete"), RequiresBotModRole] - [Description("Removes fortune with specified ID")] - public async Task Remove(CommandContext ctx, int id) - { - await using var db = new ThumbnailDb(); - var fortune = await db.Fortune.FirstOrDefaultAsync(f => f.Id == id).ConfigureAwait(false); - if (fortune is null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Fortune with id {id} wasn't found", true).ConfigureAwait(false); - return; - } + db.Fortune.Remove(fortune); + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + } - db.Fortune.Remove(fortune); - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + [Command("import"), Aliases("append"), RequiresBotModRole, TriggersTyping] + [Description("Imports new fortunes from specified URL or attachment. Data should be formatted as standard UNIX fortune source file.")] + public async Task Import(CommandContext ctx, string? url = null) + { + var msg = await ctx.Channel.SendMessageAsync("Please wait...").ConfigureAwait(false); + if (!await ImportCheck.WaitAsync(0).ConfigureAwait(false)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "There is another import in progress already").ConfigureAwait(false); + return; } - - [Command("import"), Aliases("append"), RequiresBotModRole, TriggersTyping] - [Description("Imports new fortunes from specified URL or attachment. Data should be formatted as standard UNIX fortune source file.")] - public async Task Import(CommandContext ctx, string? url = null) + + try { - var msg = await ctx.Channel.SendMessageAsync("Please wait...").ConfigureAwait(false); - if (!await ImportCheck.WaitAsync(0).ConfigureAwait(false)) + if (string.IsNullOrEmpty(url)) + url = ctx.Message.Attachments.FirstOrDefault()?.Url; + + if (string.IsNullOrEmpty(url)) { await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "There is another import in progress already").ConfigureAwait(false); return; } - - try + + var stopwatch = Stopwatch.StartNew(); + await using var db = new ThumbnailDb(); + using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + var response = await httpClient.SendAsync(request, Config.Cts.Token).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var reader = new StreamReader(stream); + var buf = new StringBuilder(); + string? line; + int count = 0, skipped = 0; + while (!Config.Cts.IsCancellationRequested + && ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null + || buf.Length > 0) + && !Config.Cts.IsCancellationRequested) { - if (string.IsNullOrEmpty(url)) - url = ctx.Message.Attachments.FirstOrDefault()?.Url; - - if (string.IsNullOrEmpty(url)) + if (line == "%" || line is null) { - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - return; - } - - var stopwatch = Stopwatch.StartNew(); - await using var db = new ThumbnailDb(); - using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); - using var request = new HttpRequestMessage(HttpMethod.Get, url); - var response = await httpClient.SendAsync(request, Config.Cts.Token).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var reader = new StreamReader(stream); - var buf = new StringBuilder(); - string? line; - int count = 0, skipped = 0; - while (!Config.Cts.IsCancellationRequested - && ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null - || buf.Length > 0) - && !Config.Cts.IsCancellationRequested) - { - if (line == "%" || line is null) + var content = buf.ToString().Replace("\r\n", "\n").Trim(); + if (content.Length > 1900) { - var content = buf.ToString().Replace("\r\n", "\n").Trim(); - if (content.Length > 1900) - { - buf.Clear(); - skipped++; - continue; - } - - if (db.Fortune.Any(f => f.Content == content)) - { - buf.Clear(); - skipped++; - continue; - } - - var duplicate = false; - foreach (var fortune in db.Fortune.AsNoTracking()) - { - if (fortune.Content.GetFuzzyCoefficientCached(content) >= 0.95) - { - duplicate = true; - break; - } - - if (Config.Cts.Token.IsCancellationRequested) - break; - } - if (duplicate) - { - buf.Clear(); - skipped++; - continue; - } - - await db.Fortune.AddAsync(new() {Content = content}).ConfigureAwait(false); - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); buf.Clear(); - count++; + skipped++; + continue; } - else - buf.AppendLine(line); - if (line is null) - break; - if (stopwatch.ElapsedMilliseconds > 10_000) + if (db.Fortune.Any(f => f.Content == content)) { - var progressMsg = $"Imported {count} fortune{(count == 1 ? "" : "s")}"; - if (skipped > 0) - progressMsg += $", skipped {skipped}"; - if (response.Content.Headers.ContentLength is long len && len > 0) - progressMsg += $" ({stream.Position * 100.0 / len:0.##}%)"; - await msg.UpdateOrCreateMessageAsync(ctx.Channel, progressMsg).ConfigureAwait(false); - stopwatch.Restart(); + buf.Clear(); + skipped++; + continue; } - } - var result = $"Imported {count} fortune{(count == 1 ? "" : "s")}"; - if (skipped > 0) - result += $", skipped {skipped}"; - await msg.UpdateOrCreateMessageAsync(ctx.Channel, result).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - } - catch (Exception e) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Failed to import data: " + e.Message).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - } - finally - { - ImportCheck.Release(); - } - } - [Command("export"), RequiresBotModRole] - [Description("Exports fortune database into UNIX fortune source format file")] - public async Task Export(CommandContext ctx) - { - try - { - var count = 0; - await using var outputStream = Config.MemoryStreamManager.GetStream(); - await using var writer = new StreamWriter(outputStream); - await using var db = new ThumbnailDb(); - foreach (var fortune in db.Fortune.AsNoTracking()) - { - if (Config.Cts.Token.IsCancellationRequested) - break; - - await writer.WriteAsync(fortune.Content).ConfigureAwait(false); - await writer.WriteAsync("\n%\n").ConfigureAwait(false); + var duplicate = false; + foreach (var fortune in db.Fortune.AsNoTracking()) + { + if (fortune.Content.GetFuzzyCoefficientCached(content) >= 0.95) + { + duplicate = true; + break; + } + + if (Config.Cts.Token.IsCancellationRequested) + break; + } + if (duplicate) + { + buf.Clear(); + skipped++; + continue; + } + + await db.Fortune.AddAsync(new() {Content = content}).ConfigureAwait(false); + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + buf.Clear(); count++; } - await writer.FlushAsync().ConfigureAwait(false); - outputStream.Seek(0, SeekOrigin.Begin); - var builder = new DiscordMessageBuilder() - .WithContent($"Exported {count} fortune{(count == 1 ? "": "s")}") - .WithFile("fortunes.txt", outputStream); - await ctx.Channel.SendMessageAsync(builder).ConfigureAwait(false); - } - catch (Exception e) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to export data: " + e.Message).ConfigureAwait(false); + else + buf.AppendLine(line); + if (line is null) + break; + + if (stopwatch.ElapsedMilliseconds > 10_000) + { + var progressMsg = $"Imported {count} fortune{(count == 1 ? "" : "s")}"; + if (skipped > 0) + progressMsg += $", skipped {skipped}"; + if (response.Content.Headers.ContentLength is long len && len > 0) + progressMsg += $" ({stream.Position * 100.0 / len:0.##}%)"; + await msg.UpdateOrCreateMessageAsync(ctx.Channel, progressMsg).ConfigureAwait(false); + stopwatch.Restart(); + } } + var result = $"Imported {count} fortune{(count == 1 ? "" : "s")}"; + if (skipped > 0) + result += $", skipped {skipped}"; + await msg.UpdateOrCreateMessageAsync(ctx.Channel, result).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); } - - [Command("clear"), RequiresBotModRole] - [Description("Clears fortune database. Use with caution")] - public async Task Clear(CommandContext ctx, [RemainingText, Description("Must be `with my blessing, I swear I exported the backup`")] string confirmation) + catch (Exception e) { - if (confirmation != "with my blessing, I swear I exported the backup") - { - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - return; - } - - await using var db = new ThumbnailDb(); - db.Fortune.RemoveRange(db.Fortune); - var count = await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, $"Removed {count} fortune{(count == 1 ? "" : "s")}", true).ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Failed to import data: " + e.Message).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + } + finally + { + ImportCheck.Release(); } } + + [Command("export"), RequiresBotModRole] + [Description("Exports fortune database into UNIX fortune source format file")] + public async Task Export(CommandContext ctx) + { + try + { + var count = 0; + await using var outputStream = Config.MemoryStreamManager.GetStream(); + await using var writer = new StreamWriter(outputStream); + await using var db = new ThumbnailDb(); + foreach (var fortune in db.Fortune.AsNoTracking()) + { + if (Config.Cts.Token.IsCancellationRequested) + break; + + await writer.WriteAsync(fortune.Content).ConfigureAwait(false); + await writer.WriteAsync("\n%\n").ConfigureAwait(false); + count++; + } + await writer.FlushAsync().ConfigureAwait(false); + outputStream.Seek(0, SeekOrigin.Begin); + var builder = new DiscordMessageBuilder() + .WithContent($"Exported {count} fortune{(count == 1 ? "": "s")}") + .WithFile("fortunes.txt", outputStream); + await ctx.Channel.SendMessageAsync(builder).ConfigureAwait(false); + } + catch (Exception e) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to export data: " + e.Message).ConfigureAwait(false); + } + } + + [Command("clear"), RequiresBotModRole] + [Description("Clears fortune database. Use with caution")] + public async Task Clear(CommandContext ctx, [RemainingText, Description("Must be `with my blessing, I swear I exported the backup`")] string confirmation) + { + if (confirmation != "with my blessing, I swear I exported the backup") + { + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + return; + } + + await using var db = new ThumbnailDb(); + db.Fortune.RemoveRange(db.Fortune); + var count = await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, $"Removed {count} fortune{(count == 1 ? "" : "s")}", true).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/CompatBot/Commands/Invites.cs b/CompatBot/Commands/Invites.cs index 8c32e0b9..889c4818 100644 --- a/CompatBot/Commands/Invites.cs +++ b/CompatBot/Commands/Invites.cs @@ -13,166 +13,165 @@ using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("invite"), Aliases("invites"), RequiresBotModRole] +[Description("Used to manage Discord invites whitelist")] +internal sealed class Invites: BaseCommandModuleCustom { - [Group("invite"), Aliases("invites"), RequiresBotModRole] - [Description("Used to manage Discord invites whitelist")] - internal sealed class Invites: BaseCommandModuleCustom + [Command("list"), Aliases("show")] + [Description("Lists all filters")] + public async Task List(CommandContext ctx) { - [Command("list"), Aliases("show")] - [Description("Lists all filters")] - public async Task List(CommandContext ctx) + const string linkPrefix = "discord.gg/"; + await using var db = new BotDb(); + var whitelistedInvites = await db.WhitelistedInvites.ToListAsync().ConfigureAwait(false); + if (whitelistedInvites.Count == 0) { - const string linkPrefix = "discord.gg/"; - await using var db = new BotDb(); - var whitelistedInvites = await db.WhitelistedInvites.ToListAsync().ConfigureAwait(false); - if (whitelistedInvites.Count == 0) - { - await ctx.Channel.SendMessageAsync("There are no whitelisted discord servers").ConfigureAwait(false); - return; - } - - var table = new AsciiTable( - new AsciiColumn("ID", alignToRight: true), - new AsciiColumn("Server ID", alignToRight: true), - new AsciiColumn("Invite", disabled: !ctx.Channel.IsPrivate), - new AsciiColumn("Server Name") - ); - foreach (var item in whitelistedInvites) - { - string? guildName = null; - if (!string.IsNullOrEmpty(item.InviteCode)) - try - { - var invite = await ctx.Client.GetInviteByCodeAsync(item.InviteCode).ConfigureAwait(false); - guildName = invite.Guild.Name; - } - catch { } - if (string.IsNullOrEmpty(guildName)) - try - { - var guild = await ctx.Client.GetGuildAsync(item.GuildId).ConfigureAwait(false); - guildName = guild.Name; - } - catch { } - if (string.IsNullOrEmpty(guildName)) - guildName = item.Name ?? ""; - var link = ""; - if (!string.IsNullOrEmpty(item.InviteCode)) - link = linkPrefix + item.InviteCode; - //discord expands invite links even if they're inside the code block for some reason - table.Add(item.Id.ToString(), item.GuildId.ToString(), link /* + StringUtils.InvisibleSpacer*/, guildName.Sanitize()); - } - var result = new StringBuilder() - .AppendLine("Whitelisted discord servers:") - .Append(table.ToString(false)); - - await using var output = Config.MemoryStreamManager.GetStream(); - await using (var writer = new StreamWriter(output, leaveOpen: true)) - await writer.WriteAsync(result.ToString()).ConfigureAwait(false); - output.Seek(0, SeekOrigin.Begin); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("invites.txt", output)).ConfigureAwait(false); + await ctx.Channel.SendMessageAsync("There are no whitelisted discord servers").ConfigureAwait(false); + return; } - [Command("whitelist"), Aliases("add", "allow"), Priority(10)] - [Description("Adds a new guild to the whitelist")] - public async Task Add(CommandContext ctx, [Description("A Discord server IDs to whitelist")] params ulong[] guildIds) + var table = new AsciiTable( + new AsciiColumn("ID", alignToRight: true), + new AsciiColumn("Server ID", alignToRight: true), + new AsciiColumn("Invite", disabled: !ctx.Channel.IsPrivate), + new AsciiColumn("Server Name") + ); + foreach (var item in whitelistedInvites) { - var errors = 0; - foreach (var guildId in guildIds) - if (!await InviteWhitelistProvider.AddAsync(guildId).ConfigureAwait(false)) - errors++; - - if (errors == 0) - await ctx.ReactWithAsync(Config.Reactions.Success, "Invite whitelist was successfully updated!").ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to add {errors} invite{StringUtils.GetSuffix(errors)} to the whitelist").ConfigureAwait(false); + string? guildName = null; + if (!string.IsNullOrEmpty(item.InviteCode)) + try + { + var invite = await ctx.Client.GetInviteByCodeAsync(item.InviteCode).ConfigureAwait(false); + guildName = invite.Guild.Name; + } + catch { } + if (string.IsNullOrEmpty(guildName)) + try + { + var guild = await ctx.Client.GetGuildAsync(item.GuildId).ConfigureAwait(false); + guildName = guild.Name; + } + catch { } + if (string.IsNullOrEmpty(guildName)) + guildName = item.Name ?? ""; + var link = ""; + if (!string.IsNullOrEmpty(item.InviteCode)) + link = linkPrefix + item.InviteCode; + //discord expands invite links even if they're inside the code block for some reason + table.Add(item.Id.ToString(), item.GuildId.ToString(), link /* + StringUtils.InvisibleSpacer*/, guildName.Sanitize()); } + var result = new StringBuilder() + .AppendLine("Whitelisted discord servers:") + .Append(table.ToString(false)); - [Command("whitelist"), Priority(0)] - [Description("Adds a new guild to the whitelist")] - public async Task Add(CommandContext ctx, [RemainingText, Description("An invite link or just an invite token")] string invite) - { - var (_, _, invites) = await ctx.Client.GetInvitesAsync(invite, tryMessageAsACode: true).ConfigureAwait(false); - if (invites.Count == 0) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Need to specify an invite link or token").ConfigureAwait(false); - return; - } - - var errors = 0; - foreach (var i in invites) - if (!await InviteWhitelistProvider.AddAsync(i).ConfigureAwait(false)) - errors++; - - if (errors == 0) - await ctx.ReactWithAsync(Config.Reactions.Success, "Invite whitelist was successfully updated!").ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to add {errors} invite{StringUtils.GetSuffix(errors)} to the whitelist").ConfigureAwait(false); - await List(ctx).ConfigureAwait(false); - } - - - [Command("update")] - [Description("Updates server invite code")] - public async Task Update(CommandContext ctx, [RemainingText, Description("An invite link or an invite token")] string invite) - { - var (_, _, invites) = await ctx.Client.GetInvitesAsync(invite, tryMessageAsACode: true).ConfigureAwait(false); - if (invites.Count == 0) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Need to specify an invite link or token").ConfigureAwait(false); - return; - } - - var errors = 0; - foreach (var i in invites) - if (!await InviteWhitelistProvider.IsWhitelistedAsync(i).ConfigureAwait(false)) - errors++; - - if (errors == 0) - await ctx.ReactWithAsync(Config.Reactions.Success, "Invite whitelist was successfully updated!").ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to update {errors} invite{StringUtils.GetSuffix(errors)}").ConfigureAwait(false); - await List(ctx).ConfigureAwait(false); - } - - [Command("rename"), Aliases("name")] - [Description("Give a custom name for a Discord server")] - public async Task Rename(CommandContext ctx, [Description("Filter ID to rename")] int id, [RemainingText, Description("Custom server name")] string name) - { - if (string.IsNullOrEmpty(name)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "A name must be provided").ConfigureAwait(false); - return; - } - - await using var db = new BotDb(); - var invite = await db.WhitelistedInvites.FirstOrDefaultAsync(i => i.Id == id).ConfigureAwait(false); - if (invite == null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Invalid filter ID").ConfigureAwait(false); - return; - } - - invite.Name = name; - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - await List(ctx).ConfigureAwait(false); - } - - [Command("remove"), Aliases("delete", "del")] - [Description("Removes server from whitelist")] - public async Task Remove(CommandContext ctx, [Description("Filter IDs to remove, separated with spaces")] params int[] ids) - { - var failedIds = new List(); - foreach (var id in ids) - if (!await InviteWhitelistProvider.RemoveAsync(id).ConfigureAwait(false)) - failedIds.Add(id); - if (failedIds.Count > 0) - await ctx.Channel.SendMessageAsync("Some IDs couldn't be removed: " + string.Join(", ", failedIds)).ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Success, $"Invite{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); - await List(ctx).ConfigureAwait(false); - } + await using var output = Config.MemoryStreamManager.GetStream(); + await using (var writer = new StreamWriter(output, leaveOpen: true)) + await writer.WriteAsync(result.ToString()).ConfigureAwait(false); + output.Seek(0, SeekOrigin.Begin); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("invites.txt", output)).ConfigureAwait(false); } -} + + [Command("whitelist"), Aliases("add", "allow"), Priority(10)] + [Description("Adds a new guild to the whitelist")] + public async Task Add(CommandContext ctx, [Description("A Discord server IDs to whitelist")] params ulong[] guildIds) + { + var errors = 0; + foreach (var guildId in guildIds) + if (!await InviteWhitelistProvider.AddAsync(guildId).ConfigureAwait(false)) + errors++; + + if (errors == 0) + await ctx.ReactWithAsync(Config.Reactions.Success, "Invite whitelist was successfully updated!").ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to add {errors} invite{StringUtils.GetSuffix(errors)} to the whitelist").ConfigureAwait(false); + } + + [Command("whitelist"), Priority(0)] + [Description("Adds a new guild to the whitelist")] + public async Task Add(CommandContext ctx, [RemainingText, Description("An invite link or just an invite token")] string invite) + { + var (_, _, invites) = await ctx.Client.GetInvitesAsync(invite, tryMessageAsACode: true).ConfigureAwait(false); + if (invites.Count == 0) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Need to specify an invite link or token").ConfigureAwait(false); + return; + } + + var errors = 0; + foreach (var i in invites) + if (!await InviteWhitelistProvider.AddAsync(i).ConfigureAwait(false)) + errors++; + + if (errors == 0) + await ctx.ReactWithAsync(Config.Reactions.Success, "Invite whitelist was successfully updated!").ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to add {errors} invite{StringUtils.GetSuffix(errors)} to the whitelist").ConfigureAwait(false); + await List(ctx).ConfigureAwait(false); + } + + + [Command("update")] + [Description("Updates server invite code")] + public async Task Update(CommandContext ctx, [RemainingText, Description("An invite link or an invite token")] string invite) + { + var (_, _, invites) = await ctx.Client.GetInvitesAsync(invite, tryMessageAsACode: true).ConfigureAwait(false); + if (invites.Count == 0) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Need to specify an invite link or token").ConfigureAwait(false); + return; + } + + var errors = 0; + foreach (var i in invites) + if (!await InviteWhitelistProvider.IsWhitelistedAsync(i).ConfigureAwait(false)) + errors++; + + if (errors == 0) + await ctx.ReactWithAsync(Config.Reactions.Success, "Invite whitelist was successfully updated!").ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Failed to update {errors} invite{StringUtils.GetSuffix(errors)}").ConfigureAwait(false); + await List(ctx).ConfigureAwait(false); + } + + [Command("rename"), Aliases("name")] + [Description("Give a custom name for a Discord server")] + public async Task Rename(CommandContext ctx, [Description("Filter ID to rename")] int id, [RemainingText, Description("Custom server name")] string name) + { + if (string.IsNullOrEmpty(name)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "A name must be provided").ConfigureAwait(false); + return; + } + + await using var db = new BotDb(); + var invite = await db.WhitelistedInvites.FirstOrDefaultAsync(i => i.Id == id).ConfigureAwait(false); + if (invite == null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Invalid filter ID").ConfigureAwait(false); + return; + } + + invite.Name = name; + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + await List(ctx).ConfigureAwait(false); + } + + [Command("remove"), Aliases("delete", "del")] + [Description("Removes server from whitelist")] + public async Task Remove(CommandContext ctx, [Description("Filter IDs to remove, separated with spaces")] params int[] ids) + { + var failedIds = new List(); + foreach (var id in ids) + if (!await InviteWhitelistProvider.RemoveAsync(id).ConfigureAwait(false)) + failedIds.Add(id); + if (failedIds.Count > 0) + await ctx.Channel.SendMessageAsync("Some IDs couldn't be removed: " + string.Join(", ", failedIds)).ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Success, $"Invite{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); + await List(ctx).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Ird.cs b/CompatBot/Commands/Ird.cs index 139444bf..128fbc4d 100644 --- a/CompatBot/Commands/Ird.cs +++ b/CompatBot/Commands/Ird.cs @@ -6,25 +6,24 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using IrdLibraryClient; -namespace CompatBot.Commands -{ - internal sealed class Ird: BaseCommandModuleCustom - { - private static readonly IrdClient Client = new(); +namespace CompatBot.Commands; - [Command("ird"), TriggersTyping] - [Description("Searches IRD Library for the matching .ird files")] - public async Task Search(CommandContext ctx, [RemainingText, Description("Product code or game title to look up")] string query) +internal sealed class Ird: BaseCommandModuleCustom +{ + private static readonly IrdClient Client = new(); + + [Command("ird"), TriggersTyping] + [Description("Searches IRD Library for the matching .ird files")] + public async Task Search(CommandContext ctx, [RemainingText, Description("Product code or game title to look up")] string query) + { + if (string.IsNullOrEmpty(query)) { - if (string.IsNullOrEmpty(query)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Can't search for nothing, boss").ConfigureAwait(false); - return; - } - - var result = await Client.SearchAsync(query, Config.Cts.Token).ConfigureAwait(false); - var embed = result.AsEmbed(); - await ctx.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Can't search for nothing, boss").ConfigureAwait(false); + return; } + + var result = await Client.SearchAsync(query, Config.Cts.Token).ConfigureAwait(false); + var embed = result.AsEmbed(); + await ctx.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); } -} +} \ No newline at end of file diff --git a/CompatBot/Commands/Minesweeper.cs b/CompatBot/Commands/Minesweeper.cs index 0928ad77..a09c81e7 100644 --- a/CompatBot/Commands/Minesweeper.cs +++ b/CompatBot/Commands/Minesweeper.cs @@ -6,133 +6,132 @@ using CompatBot.Commands.Attributes; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("minesweeper"), Aliases("msgen")] +[LimitedToOfftopicChannel, Cooldown(1, 30, CooldownBucketType.Channel)] +[Description("Generates a minesweeper field with specified parameters")] +internal sealed class Minesweeper : BaseCommandModuleCustom { - [Group("minesweeper"), Aliases("msgen")] - [LimitedToOfftopicChannel, Cooldown(1, 30, CooldownBucketType.Channel)] - [Description("Generates a minesweeper field with specified parameters")] - internal sealed class Minesweeper : BaseCommandModuleCustom + //private static readonly string[] Numbers = {"0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣",}; + private static readonly string[] Numbers = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",}; + private static readonly string[] Bombs = {"*", "◎"}; + private static readonly int MaxBombLength; + + static Minesweeper() { - //private static readonly string[] Numbers = {"0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣",}; - private static readonly string[] Numbers = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",}; - private static readonly string[] Bombs = {"*", "◎"}; - private static readonly int MaxBombLength; + MaxBombLength = Bombs.Select(b => b.Length).Max(); + } - static Minesweeper() - { - MaxBombLength = Bombs.Select(b => b.Length).Max(); - } - - private enum CellVal: byte - { - Zero = 0, - One = 1, - Two = 2, - Three = 3, - Four = 4, - Five = 5, - Six = 6, - Seven = 7, - Eight = 8, + private enum CellVal: byte + { + Zero = 0, + One = 1, + Two = 2, + Three = 3, + Four = 4, + Five = 5, + Six = 6, + Seven = 7, + Eight = 8, - OpenZero = 100, + OpenZero = 100, - Mine = 255, + Mine = 255, + } + + [GroupCommand] + public async Task Generate(CommandContext ctx, + [Description("Width of the field")] int width = 14, + [Description("Height of the field")] int height = 14, + [Description("Number of mines")] int mineCount = 30) + { + if (width < 3 || height < 3 || mineCount < 1) + { + await ctx.Channel.SendMessageAsync("Invalid generation parameters").ConfigureAwait(false); + return; } - [GroupCommand] - public async Task Generate(CommandContext ctx, - [Description("Width of the field")] int width = 14, - [Description("Height of the field")] int height = 14, - [Description("Number of mines")] int mineCount = 30) + var header = $"{mineCount}x💣\n"; + var footer = "If something is cut off, blame Discord"; + var maxMineCount = (width - 1) * (height - 1) * 2 / 3; + if (mineCount > maxMineCount) { - if (width < 3 || height < 3 || mineCount < 1) - { - await ctx.Channel.SendMessageAsync("Invalid generation parameters").ConfigureAwait(false); - return; - } - - var header = $"{mineCount}x💣\n"; - var footer = "If something is cut off, blame Discord"; - var maxMineCount = (width - 1) * (height - 1) * 2 / 3; - if (mineCount > maxMineCount) - { - await ctx.Channel.SendMessageAsync("Isn't this a bit too many mines 🤔").ConfigureAwait(false); - return; - } - - if (height > 98) - { - await ctx.Channel.SendMessageAsync("Too many lines for one message, Discord would truncate the result randomly").ConfigureAwait(false); - return; - } - - var msgLen = (4 * width * height - 4) + (height - 1) + mineCount * MaxBombLength + (width * height - mineCount) * "0️⃣".Length + header.Length; - if (width * height > 198 || msgLen > 2000) // for some reason discord would cut everything beyond 198 cells even if the content length is well within the limits - { - await ctx.Channel.SendMessageAsync("Requested field size is too large for one message").ConfigureAwait(false); - return; - } - - var rng = new Random(); - var field = GenerateField(width, height, mineCount, rng); - var result = new StringBuilder(msgLen).Append(header); - var bomb = rng.NextDouble() > 0.9 ? Bombs[rng.Next(Bombs.Length)] : Bombs[0]; - var needOneOpenCell = true; - for (var y = 0; y < height; y++) - { - for (var x = 0; x < width; x++) - { - var c = (CellVal)field[y, x] == CellVal.Mine ? bomb : Numbers[field[y, x]]; - if (needOneOpenCell && field[y, x] == 0) - { - result.Append(c); - needOneOpenCell = false; - } - else - result.Append("||").Append(c).Append("||"); - } - result.Append('\n'); - } - result.Append(footer); - await ctx.Channel.SendMessageAsync(result.ToString()).ConfigureAwait(false); + await ctx.Channel.SendMessageAsync("Isn't this a bit too many mines 🤔").ConfigureAwait(false); + return; } - private static byte[,] GenerateField(int width, int height, in int mineCount, in Random rng) + if (height > 98) { - var len = width * height; - var cells = new byte[len]; - // put mines - for (var i = 0; i < mineCount; i++) - cells[i] = (byte)CellVal.Mine; + await ctx.Channel.SendMessageAsync("Too many lines for one message, Discord would truncate the result randomly").ConfigureAwait(false); + return; + } - //shuffle the board - for (var i = 0; i < len - 1; i++) - { - var j = rng.Next(i, len); - (cells[i], cells[j]) = (cells[j], cells[i]); - } - var result = new byte[height, width]; - Buffer.BlockCopy(cells, 0, result, 0, len); + var msgLen = (4 * width * height - 4) + (height - 1) + mineCount * MaxBombLength + (width * height - mineCount) * "0️⃣".Length + header.Length; + if (width * height > 198 || msgLen > 2000) // for some reason discord would cut everything beyond 198 cells even if the content length is well within the limits + { + await ctx.Channel.SendMessageAsync("Requested field size is too large for one message").ConfigureAwait(false); + return; + } - //update mine indicators - byte get(int x, int y) => x < 0 || x >= width || y < 0 || y >= height ? (byte)0 : result[y, x]; - - byte countMines(int x, int y) - { - byte c = 0; - for (var yy = y - 1; yy <= y + 1; yy++) - for (var xx = x - 1; xx <= x + 1; xx++) - if ((CellVal)get(xx, yy) == CellVal.Mine) - c++; - return c; - } - - for (var y = 0; y < height; y++) + var rng = new Random(); + var field = GenerateField(width, height, mineCount, rng); + var result = new StringBuilder(msgLen).Append(header); + var bomb = rng.NextDouble() > 0.9 ? Bombs[rng.Next(Bombs.Length)] : Bombs[0]; + var needOneOpenCell = true; + for (var y = 0; y < height; y++) + { for (var x = 0; x < width; x++) - if ((CellVal)result[y, x] != CellVal.Mine) - result[y, x] = countMines(x, y); - return result; + { + var c = (CellVal)field[y, x] == CellVal.Mine ? bomb : Numbers[field[y, x]]; + if (needOneOpenCell && field[y, x] == 0) + { + result.Append(c); + needOneOpenCell = false; + } + else + result.Append("||").Append(c).Append("||"); + } + result.Append('\n'); } + result.Append(footer); + await ctx.Channel.SendMessageAsync(result.ToString()).ConfigureAwait(false); + } + + private static byte[,] GenerateField(int width, int height, in int mineCount, in Random rng) + { + var len = width * height; + var cells = new byte[len]; + // put mines + for (var i = 0; i < mineCount; i++) + cells[i] = (byte)CellVal.Mine; + + //shuffle the board + for (var i = 0; i < len - 1; i++) + { + var j = rng.Next(i, len); + (cells[i], cells[j]) = (cells[j], cells[i]); + } + var result = new byte[height, width]; + Buffer.BlockCopy(cells, 0, result, 0, len); + + //update mine indicators + byte get(int x, int y) => x < 0 || x >= width || y < 0 || y >= height ? (byte)0 : result[y, x]; + + byte countMines(int x, int y) + { + byte c = 0; + for (var yy = y - 1; yy <= y + 1; yy++) + for (var xx = x - 1; xx <= x + 1; xx++) + if ((CellVal)get(xx, yy) == CellVal.Mine) + c++; + return c; + } + + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + if ((CellVal)result[y, x] != CellVal.Mine) + result[y, x] = countMines(x, y); + return result; } } \ No newline at end of file diff --git a/CompatBot/Commands/Misc.cs b/CompatBot/Commands/Misc.cs index d7da8aec..9705b867 100644 --- a/CompatBot/Commands/Misc.cs +++ b/CompatBot/Commands/Misc.cs @@ -14,543 +14,542 @@ using DSharpPlus.Entities; using HomoglyphConverter; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal sealed class Misc: BaseCommandModuleCustom { - internal sealed class Misc: BaseCommandModuleCustom + private static readonly Random rng = new(); + + private static readonly List EightBallAnswers = new() { - private static readonly Random rng = new(); + // keep this at 2:1:1 ratio + // 70 + "It is certain", "It is decidedly so", "Without a doubt", "Yes definitely", "You may rely on it", + "As I see it, yes", "Most likely", "Outlook good", "Yes", "Signs point to yes", // 10 + "Ya fo sho", "Fo shizzle mah nizzle", "Yuuuup", "Da", "Affirmative", + "Sure", "Yeah, why not", "Sim", "Oui", "Crystal ball says yes", // 20 + "Heck yeah!", "Roger that", "Aye!", "Yes without a doubt m8!", ":cell_ok_hand_hd:", + "Don't be an idiot. YES.", "Mhm!", "Many Yes", "Yiss", "Sir, yes, Sir!", // 30 + "Yah!", "Ja", "Umu!", "Make it so", "Sure thing", + "Certainly", "Of course", "Definitely", "Indeed", "Much yes", // 40 + "Consider it done", "Totally", "You bet", "Yup", "Yep", + "Positive!", "Yarp", "Hmmm, yes!", "That's a yes for me", "Aye mate", // 50 + "Absolutely", "Totes my goats", "Without fail", "👌", "👍", + "Sí", "Sí, señor", "Sì", "Sì, signore", "The wheel of fate is already turning", // 60 + "It's not a no", "Very likely", "Undoubtedly so", "That's a positive", "Yes, you silly", + "Bones said yes", "Tea leaves settled in a 'yes' pattern", "Dice roll was solid, so yes", "No doubt about it", "Hmmm, I think so", // 70 - private static readonly List EightBallAnswers = new() - { - // keep this at 2:1:1 ratio - // 70 - "It is certain", "It is decidedly so", "Without a doubt", "Yes definitely", "You may rely on it", - "As I see it, yes", "Most likely", "Outlook good", "Yes", "Signs point to yes", // 10 - "Ya fo sho", "Fo shizzle mah nizzle", "Yuuuup", "Da", "Affirmative", - "Sure", "Yeah, why not", "Sim", "Oui", "Crystal ball says yes", // 20 - "Heck yeah!", "Roger that", "Aye!", "Yes without a doubt m8!", ":cell_ok_hand_hd:", - "Don't be an idiot. YES.", "Mhm!", "Many Yes", "Yiss", "Sir, yes, Sir!", // 30 - "Yah!", "Ja", "Umu!", "Make it so", "Sure thing", - "Certainly", "Of course", "Definitely", "Indeed", "Much yes", // 40 - "Consider it done", "Totally", "You bet", "Yup", "Yep", - "Positive!", "Yarp", "Hmmm, yes!", "That's a yes for me", "Aye mate", // 50 - "Absolutely", "Totes my goats", "Without fail", "👌", "👍", - "Sí", "Sí, señor", "Sì", "Sì, signore", "The wheel of fate is already turning", // 60 - "It's not a no", "Very likely", "Undoubtedly so", "That's a positive", "Yes, you silly", - "Bones said yes", "Tea leaves settled in a 'yes' pattern", "Dice roll was solid, so yes", "No doubt about it", "Hmmm, I think so", // 70 + // 30 + "Reply hazy, try again", "Ask again later", "Better not tell you now", "Cannot predict now", "Concentrate and ask again", + "Maybe", "I don't know", "I don't care", "Who cares", "Maybe yes, maybe not", // 10 + "Maybe not, maybe yes", "Ugh", "Probably", "Error 404: answer not found", "Crystal ball is cloudy as milk, ask later", + "Don't ask me that again", "You should think twice before asking", "You what now?", "Ask Neko", "Ask Ani", // 20 + "Bloody hell, answering that ain't so easy", "I'm pretty sure that's illegal!", "What do *you* think?", "Only on Wednesdays", "Look in the mirror, you know the answer already", + "Don't know, don't care", "_shows signs of complete confusion_", "Have you googled it?", "Not sure my dude", "🤔", // 30 - // 30 - "Reply hazy, try again", "Ask again later", "Better not tell you now", "Cannot predict now", "Concentrate and ask again", - "Maybe", "I don't know", "I don't care", "Who cares", "Maybe yes, maybe not", // 10 - "Maybe not, maybe yes", "Ugh", "Probably", "Error 404: answer not found", "Crystal ball is cloudy as milk, ask later", - "Don't ask me that again", "You should think twice before asking", "You what now?", "Ask Neko", "Ask Ani", // 20 - "Bloody hell, answering that ain't so easy", "I'm pretty sure that's illegal!", "What do *you* think?", "Only on Wednesdays", "Look in the mirror, you know the answer already", - "Don't know, don't care", "_shows signs of complete confusion_", "Have you googled it?", "Not sure my dude", "🤔", // 30 + // 40 + "Don't count on it", "My reply is no", "My sources say no", "Outlook not so good", "Very doubtful", + "Nah mate", "Nope", "Njet", "Of course not", "Seriously no", // 10 + "Noooooooooo", "Most likely not", "Não", "Non", "Hell no", + "Absolutely not", "Nuh-uh!", "Nyet!", "Negatory!", "Heck no", // 20 + "Nein!", "I think not", "I'm afraid not", "Nay", "Yesn't", + "No way", "Certainly not", "I must say no", "Nah", "Negative", // 30 + "Definitely not", "No way, Jose", "Not today", "No no no no no no no no no no. No.", "Not in a million years", + "I'm afraid I can't let you do that Dave.", "This mission is too important for me to allow you to jeopardize it.", "Oh, I don't think so", "By *no* means", "👎", // 40 + }; - // 40 - "Don't count on it", "My reply is no", "My sources say no", "Outlook not so good", "Very doubtful", - "Nah mate", "Nope", "Njet", "Of course not", "Seriously no", // 10 - "Noooooooooo", "Most likely not", "Não", "Non", "Hell no", - "Absolutely not", "Nuh-uh!", "Nyet!", "Negatory!", "Heck no", // 20 - "Nein!", "I think not", "I'm afraid not", "Nay", "Yesn't", - "No way", "Certainly not", "I must say no", "Nah", "Negative", // 30 - "Definitely not", "No way, Jose", "Not today", "No no no no no no no no no no. No.", "Not in a million years", - "I'm afraid I can't let you do that Dave.", "This mission is too important for me to allow you to jeopardize it.", "Oh, I don't think so", "By *no* means", "👎", // 40 - }; + private static readonly List EightBallSnarkyComments = new() + { + "Can't answer the question that wasn't asked", + "Having issues with my mind reading attachment, you'll have to state your question explicitly", + "Bad reception on your brain waves today, can't read the question", + "What should the answer be for the question that wasn't asked 🤔", + "In Discord no one can read your question if you don't type it", + "In space no one can hear you scream; that's what you're doing right now", + "Unfortunately there's no technology to transmit your question telepathically just yet", + "I'd say maybe, but I'd need to see your question first", + }; - private static readonly List EightBallSnarkyComments = new() - { - "Can't answer the question that wasn't asked", - "Having issues with my mind reading attachment, you'll have to state your question explicitly", - "Bad reception on your brain waves today, can't read the question", - "What should the answer be for the question that wasn't asked 🤔", - "In Discord no one can read your question if you don't type it", - "In space no one can hear you scream; that's what you're doing right now", - "Unfortunately there's no technology to transmit your question telepathically just yet", - "I'd say maybe, but I'd need to see your question first", - }; + private static readonly List EightBallTimeUnits = new() + { + "second", "minute", "hour", "day", "week", "month", "year", "decade", "century", "millennium", + "night", "moon cycle", "solar eclipse", "blood moon", "complete emulator rewrite", + }; - private static readonly List EightBallTimeUnits = new() - { - "second", "minute", "hour", "day", "week", "month", "year", "decade", "century", "millennium", - "night", "moon cycle", "solar eclipse", "blood moon", "complete emulator rewrite", - }; + private static readonly List RateAnswers = new() + { + // 60 + "Not so bad", "I likesss!", "Pretty good", "Guchi gud", "Amazing!", + "Glorious!", "Very good", "Excellent...", "Magnificent", "Rate bot says he likes, so you like too", + "If you reorganize the words it says \"pretty cool\"", "I approve", "<:morgana_sparkle:315899996274688001> やるじゃねーか!", "Not half bad 👍", "Belissimo!", + "Cool. Cool cool cool", "I am in awe", "Incredible!", "Radiates gloriousness", "Like a breath of fresh air", + "Sunshine for my digital soul 🌞", "Fantastic like petrichor 🌦", "Joyous like a rainbow 🌈", "Unbelievably good", "Can't recommend enough", + "Not perfect, but ok", "So good!", "A lucky find!", "💯 approved", "I don't see any downsides", + "Here's my seal of approval 💮", "As good as it gets", "A benchmark to pursue", "Should make you warm and fuzzy inside", "Fabulous", + "Cool like a cup of good wine 🍷", "Magical ✨", "Wondrous like a unicorn 🦄", "Soothing sight for these tired eyes", "Lovely", + "So cute!", "It's so nice, I think about it every day!", "😊 Never expected to be this pretty!", "It's overflowing with charm!", "Filled with passion!", + "A love magnet", "Pretty Fancy", "Admirable", "Sweet as a candy", "Delightful", + "Enchanting as the Sunset", "A beacon of hope!", "Filled with hope!", "Shiny!", "Absolute Hope!", + "The meaning of hope", "Inspiring!", "Marvelous", "Breathtaking", "Better than bubble wrap.", - private static readonly List RateAnswers = new() - { - // 60 - "Not so bad", "I likesss!", "Pretty good", "Guchi gud", "Amazing!", - "Glorious!", "Very good", "Excellent...", "Magnificent", "Rate bot says he likes, so you like too", - "If you reorganize the words it says \"pretty cool\"", "I approve", "<:morgana_sparkle:315899996274688001> やるじゃねーか!", "Not half bad 👍", "Belissimo!", - "Cool. Cool cool cool", "I am in awe", "Incredible!", "Radiates gloriousness", "Like a breath of fresh air", - "Sunshine for my digital soul 🌞", "Fantastic like petrichor 🌦", "Joyous like a rainbow 🌈", "Unbelievably good", "Can't recommend enough", - "Not perfect, but ok", "So good!", "A lucky find!", "💯 approved", "I don't see any downsides", - "Here's my seal of approval 💮", "As good as it gets", "A benchmark to pursue", "Should make you warm and fuzzy inside", "Fabulous", - "Cool like a cup of good wine 🍷", "Magical ✨", "Wondrous like a unicorn 🦄", "Soothing sight for these tired eyes", "Lovely", - "So cute!", "It's so nice, I think about it every day!", "😊 Never expected to be this pretty!", "It's overflowing with charm!", "Filled with passion!", - "A love magnet", "Pretty Fancy", "Admirable", "Sweet as a candy", "Delightful", - "Enchanting as the Sunset", "A beacon of hope!", "Filled with hope!", "Shiny!", "Absolute Hope!", - "The meaning of hope", "Inspiring!", "Marvelous", "Breathtaking", "Better than bubble wrap.", + // 22 + "Ask MsLow", "Could be worse", "I need more time to think about it", "It's ok, nothing and no one is perfect", "🆗", + "You already know, my boi", "Unexpected like a bouquet of sunflowers 🌻", "Hard to measure precisely...", "Requires more data to analyze", "Passable", + "Quite unique 🤔", "Less like an orange, and more like an apple", "I don't know, man...", "It is so tiring to grade everything...", "...", + "Bland like porridge", "🤔", "Ok-ish?", "Not _bad_, but also not _good_", "Why would you want to _rate_ this?", "meh", + "I've seen worse", - // 22 - "Ask MsLow", "Could be worse", "I need more time to think about it", "It's ok, nothing and no one is perfect", "🆗", - "You already know, my boi", "Unexpected like a bouquet of sunflowers 🌻", "Hard to measure precisely...", "Requires more data to analyze", "Passable", - "Quite unique 🤔", "Less like an orange, and more like an apple", "I don't know, man...", "It is so tiring to grade everything...", "...", - "Bland like porridge", "🤔", "Ok-ish?", "Not _bad_, but also not _good_", "Why would you want to _rate_ this?", "meh", - "I've seen worse", + // 43 + "Bad", "Very bad", "Pretty bad", "Horrible", "Ugly", + "Disgusting", "Literally the worst", "Not interesting", "Simply ugh", "I don't like it! You shouldn't either!", + "Just like you, 💩", "Not approved", "Big Mistake", "The opposite of good", "Could be better", + "🤮", "😐", "So-so", "Not worth it", "Mediocre at best", + "Useless", "I think you misspelled `poop` there", "Nothing special", "😔", "Real shame", + "Boooooooo!", "Poopy", "Smelly", "Feeling-breaker", "Annoying", + "Boring", "Easily forgettable", "An Abomination", "A Monstrosity", "Truly horrific", + "Filled with despair!", "Eroded by despair", "Hopeless…", "It's pretty foolish to want to rate this", "Cursed with misfortune", + "Nothing but terror", "Not good, at all", "A waste of time", + }; - // 43 - "Bad", "Very bad", "Pretty bad", "Horrible", "Ugly", - "Disgusting", "Literally the worst", "Not interesting", "Simply ugh", "I don't like it! You shouldn't either!", - "Just like you, 💩", "Not approved", "Big Mistake", "The opposite of good", "Could be better", - "🤮", "😐", "So-so", "Not worth it", "Mediocre at best", - "Useless", "I think you misspelled `poop` there", "Nothing special", "😔", "Real shame", - "Boooooooo!", "Poopy", "Smelly", "Feeling-breaker", "Annoying", - "Boring", "Easily forgettable", "An Abomination", "A Monstrosity", "Truly horrific", - "Filled with despair!", "Eroded by despair", "Hopeless…", "It's pretty foolish to want to rate this", "Cursed with misfortune", - "Nothing but terror", "Not good, at all", "A waste of time", - }; + private static readonly char[] Separators = { ' ', ' ', '\r', '\n' }; + private static readonly char[] Suffixes = {',', '.', ':', ';', '!', '?', ')', '}', ']', '>', '+', '-', '/', '*', '=', '"', '\'', '`'}; + private static readonly char[] Prefixes = {'@', '(', '{', '[', '<', '!', '`', '"', '\'', '#'}; + private static readonly char[] EveryTimable = Separators.Concat(Suffixes).Concat(Prefixes).Distinct().ToArray(); - private static readonly char[] Separators = { ' ', ' ', '\r', '\n' }; - private static readonly char[] Suffixes = {',', '.', ':', ';', '!', '?', ')', '}', ']', '>', '+', '-', '/', '*', '=', '"', '\'', '`'}; - private static readonly char[] Prefixes = {'@', '(', '{', '[', '<', '!', '`', '"', '\'', '#'}; - private static readonly char[] EveryTimable = Separators.Concat(Suffixes).Concat(Prefixes).Distinct().ToArray(); + private static readonly HashSet Me = new(StringComparer.InvariantCultureIgnoreCase) + { + "I", "me", "myself", "moi" + }; - private static readonly HashSet Me = new(StringComparer.InvariantCultureIgnoreCase) - { - "I", "me", "myself", "moi" - }; + private static readonly HashSet Your = new(StringComparer.InvariantCultureIgnoreCase) + { + "your", "you're", "yor", "ur", "yours", "your's", + }; - private static readonly HashSet Your = new(StringComparer.InvariantCultureIgnoreCase) - { - "your", "you're", "yor", "ur", "yours", "your's", - }; + private static readonly HashSet Vowels = new() {'a', 'e', 'i', 'o', 'u'}; - private static readonly HashSet Vowels = new() {'a', 'e', 'i', 'o', 'u'}; + private static readonly Regex Instead = new("rate (?.+) instead", RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Singleline); - private static readonly Regex Instead = new("rate (?.+) instead", RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.Singleline); + [Command("credits"), Aliases("about")] + [Description("Author Credit")] + public async Task About(CommandContext ctx) + { + var hcorion = ctx.Client.GetEmoji(":hcorion:", DiscordEmoji.FromUnicode("🍁")); + var clienthax = ctx.Client.GetEmoji(":gooseknife:", DiscordEmoji.FromUnicode("🐱")); + var embed = new DiscordEmbedBuilder + { + Title = "RPCS3 Compatibility Bot", + Url = "https://github.com/RPCS3/discord-bot", + Color = DiscordColor.Purple, + }.AddField("Made by", + "💮 13xforever\n" + + "🇭🇷 Roberto Anić Banić aka nicba1010\n" + + $"{clienthax} clienthax\n" + ) + .AddField("People who ~~broke~~ helped test the bot", + "🐱 Juhn\n" + + $"{hcorion} hcorion\n" + + "🙃 TGE\n" + + "🍒 Maru\n" + + "♋ Tourghool"); + await ctx.Channel.SendMessageAsync(embed: embed.Build()); + } - [Command("credits"), Aliases("about")] - [Description("Author Credit")] - public async Task About(CommandContext ctx) - { - var hcorion = ctx.Client.GetEmoji(":hcorion:", DiscordEmoji.FromUnicode("🍁")); - var clienthax = ctx.Client.GetEmoji(":gooseknife:", DiscordEmoji.FromUnicode("🐱")); - var embed = new DiscordEmbedBuilder - { - Title = "RPCS3 Compatibility Bot", - Url = "https://github.com/RPCS3/discord-bot", - Color = DiscordColor.Purple, - }.AddField("Made by", - "💮 13xforever\n" + - "🇭🇷 Roberto Anić Banić aka nicba1010\n" + - $"{clienthax} clienthax\n" - ) - .AddField("People who ~~broke~~ helped test the bot", - "🐱 Juhn\n" + - $"{hcorion} hcorion\n" + - "🙃 TGE\n" + - "🍒 Maru\n" + - "♋ Tourghool"); - await ctx.Channel.SendMessageAsync(embed: embed.Build()); - } + [Command("roll")] + [Description("Generates a random number between 1 and maxValue. Can also roll dices like `2d6`. Default is 1d6")] + public Task Roll(CommandContext ctx, [Description("Some positive natural number")] int maxValue = 6, [RemainingText, Description("Optional text")] string? comment = null) + => RollImpl(ctx.Message, maxValue); - [Command("roll")] - [Description("Generates a random number between 1 and maxValue. Can also roll dices like `2d6`. Default is 1d6")] - public Task Roll(CommandContext ctx, [Description("Some positive natural number")] int maxValue = 6, [RemainingText, Description("Optional text")] string? comment = null) - => RollImpl(ctx.Message, maxValue); - - [Command("roll")] - public Task Roll(CommandContext ctx, [RemainingText, Description("Dices to roll (i.e. 2d6+1 for two 6-sided dices with a bonus 1)")] string dices) - => RollImpl(ctx.Message, dices); + [Command("roll")] + public Task Roll(CommandContext ctx, [RemainingText, Description("Dices to roll (i.e. 2d6+1 for two 6-sided dices with a bonus 1)")] string dices) + => RollImpl(ctx.Message, dices); - internal static async Task RollImpl(DiscordMessage message, int maxValue = 6) - { - string? result = null; - if (maxValue > 1) - lock (rng) result = (rng.Next(maxValue) + 1).ToString(); - if (string.IsNullOrEmpty(result)) - await message.ReactWithAsync(DiscordEmoji.FromUnicode("💩"), $"How is {maxValue} a positive natural number?").ConfigureAwait(false); - else - await message.Channel.SendMessageAsync(result).ConfigureAwait(false); - } + internal static async Task RollImpl(DiscordMessage message, int maxValue = 6) + { + string? result = null; + if (maxValue > 1) + lock (rng) result = (rng.Next(maxValue) + 1).ToString(); + if (string.IsNullOrEmpty(result)) + await message.ReactWithAsync(DiscordEmoji.FromUnicode("💩"), $"How is {maxValue} a positive natural number?").ConfigureAwait(false); + else + await message.Channel.SendMessageAsync(result).ConfigureAwait(false); + } - internal static async Task RollImpl(DiscordMessage message, string dices) + internal static async Task RollImpl(DiscordMessage message, string dices) + { + var result = ""; + var embed = new DiscordEmbedBuilder(); + if (dices is string dice && Regex.Matches(dice, @"(?\d+)?d(?\d+)(?:\+(?\d+))?") is {Count: > 0 and <= EmbedPager.MaxFields } matches) { - var result = ""; - var embed = new DiscordEmbedBuilder(); - if (dices is string dice && Regex.Matches(dice, @"(?\d+)?d(?\d+)(?:\+(?\d+))?") is {Count: > 0 and <= EmbedPager.MaxFields } matches) + var grandTotal = 0; + foreach (Match m in matches) { - var grandTotal = 0; - foreach (Match m in matches) + result = ""; + if (!int.TryParse(m.Groups["num"].Value, out var num)) + num = 1; + if (int.TryParse(m.Groups["face"].Value, out var face) + && 0 < num && num < 101 + && 1 < face && face < 1001) { - result = ""; - if (!int.TryParse(m.Groups["num"].Value, out var num)) - num = 1; - if (int.TryParse(m.Groups["face"].Value, out var face) - && 0 < num && num < 101 - && 1 < face && face < 1001) + List rolls; + lock (rng) rolls = Enumerable.Range(0, num).Select(_ => rng.Next(face) + 1).ToList(); + var total = rolls.Sum(); + var totalStr = total.ToString(); + if (int.TryParse(m.Groups["mod"].Value, out var mod) && mod > 0) + totalStr += $" + {mod} = {total + mod}"; + var rollsStr = string.Join(' ', rolls); + if (rolls.Count > 1) { - List rolls; - lock (rng) rolls = Enumerable.Range(0, num).Select(_ => rng.Next(face) + 1).ToList(); - var total = rolls.Sum(); - var totalStr = total.ToString(); - if (int.TryParse(m.Groups["mod"].Value, out var mod) && mod > 0) - totalStr += $" + {mod} = {total + mod}"; - var rollsStr = string.Join(' ', rolls); - if (rolls.Count > 1) - { - result = "Total: " + totalStr; - result += "\nRolls: " + rollsStr; - } - else - result = totalStr; - grandTotal += total + mod; - var diceDesc = $"{num}d{face}"; - if (mod > 0) - diceDesc += "+" + mod; - embed.AddField(diceDesc, result.Trim(EmbedPager.MaxFieldLength), true); + result = "Total: " + totalStr; + result += "\nRolls: " + rollsStr; } - } - if (matches.Count == 1) - embed = null; - else - { - embed.Description = "Grand total: " + grandTotal; - embed.Title = $"Result of {matches.Count} dice rolls"; - embed.Color = Config.Colors.Help; - result = null; + else + result = totalStr; + grandTotal += total + mod; + var diceDesc = $"{num}d{face}"; + if (mod > 0) + diceDesc += "+" + mod; + embed.AddField(diceDesc, result.Trim(EmbedPager.MaxFieldLength), true); } } + if (matches.Count == 1) + embed = null; else { - await RollImpl(message).ConfigureAwait(false); - return; - } - - if (string.IsNullOrEmpty(result) && embed == null) - await message.ReactWithAsync(DiscordEmoji.FromUnicode("💩"), "Invalid dice description passed").ConfigureAwait(false); - else if (embed != null) - await message.Channel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(embed).WithReply(message.Id)).ConfigureAwait(false); - else - await message.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent(result).WithReply(message.Id)).ConfigureAwait(false); - } - - [Command("random"), Aliases("rng"), Hidden, Cooldown(1, 3, CooldownBucketType.Channel)] - [Description("Provides random stuff")] - public async Task RandomShit(CommandContext ctx, string stuff) - { - stuff = stuff.ToLowerInvariant(); - switch (stuff) - { - case "game": - case "serial": - case "productcode": - case "product code": - { - await using var db = new ThumbnailDb(); - var count = await db.Thumbnail.CountAsync().ConfigureAwait(false); - if (count == 0) - { - await ctx.Channel.SendMessageAsync("Sorry, I have no information about a single game yet").ConfigureAwait(false); - return; - } - - var tmpRng = new Random().Next(count); - var productCode = await db.Thumbnail.Skip(tmpRng).Take(1).FirstOrDefaultAsync().ConfigureAwait(false); - if (productCode == null) - { - await ctx.Channel.SendMessageAsync("Sorry, there's something with my brains today. Try again or something").ConfigureAwait(false); - return; - } - - await ProductCodeLookup.LookupAndPostProductCodeEmbedAsync(ctx.Client, ctx.Message, ctx.Channel, new() {productCode.ProductCode}).ConfigureAwait(false); - break; - } - default: - await Roll(ctx, comment: stuff).ConfigureAwait(false); - break; + embed.Description = "Grand total: " + grandTotal; + embed.Title = $"Result of {matches.Count} dice rolls"; + embed.Color = Config.Colors.Help; + result = null; } } - - [Command("8ball"), Cooldown(20, 60, CooldownBucketType.Channel)] - [Description("Provides a ~~random~~ objectively best answer to your question")] - public async Task EightBall(CommandContext ctx, [RemainingText, Description("A yes/no question")] string question) + else { - question = question.ToLowerInvariant(); - if (question.StartsWith("when ")) - await When(ctx, question[5..]).ConfigureAwait(false); - else - { - string answer; - var pool = string.IsNullOrEmpty(question) ? EightBallSnarkyComments : EightBallAnswers; - lock (rng) answer = pool[rng.Next(pool.Count)]; - if (answer.StartsWith(':') && answer.EndsWith(':')) - answer = ctx.Client.GetEmoji(answer, "🔮"); - await ctx.RespondAsync(answer).ConfigureAwait(false); - } + await RollImpl(message).ConfigureAwait(false); + return; } - [Command("when"), Hidden, Cooldown(20, 60, CooldownBucketType.Channel)] - [Description("Provides advanced clairvoyance services to predict the time frame for specified event with maximum accuracy")] - public async Task When(CommandContext ctx, [RemainingText, Description("Something to happen")] string something = "") - { - var question = something.Trim().TrimEnd('?').ToLowerInvariant().StripInvisibleAndDiacritics().ToCanonicalForm(); - var prefix = DateTime.UtcNow.ToString("yyyyMMddHH"); - var crng = new Random((prefix + question).GetHashCode()); - var number = crng.Next(100) + 1; - var unit = EightBallTimeUnits[crng.Next(EightBallTimeUnits.Count)]; - if (number > 1) - { - if (unit.EndsWith("ry")) - unit = unit[..^1] + "ie"; - unit += "s"; - if (unit == "millenniums") - unit = "millennia"; - } - var willWont = crng.NextDouble() < 0.5 ? "will" : "won't"; - await ctx.RespondAsync($"🔮 My psychic powers tell me it {willWont} happen in the next **{number} {unit}** 🔮").ConfigureAwait(false); - } + if (string.IsNullOrEmpty(result) && embed == null) + await message.ReactWithAsync(DiscordEmoji.FromUnicode("💩"), "Invalid dice description passed").ConfigureAwait(false); + else if (embed != null) + await message.Channel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(embed).WithReply(message.Id)).ConfigureAwait(false); + else + await message.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent(result).WithReply(message.Id)).ConfigureAwait(false); + } - [Group("how"), Hidden, Cooldown(20, 60, CooldownBucketType.Channel)] - [Description("Provides advanced clairvoyance services to predict the exact amount of anything that could be measured")] - public class How: BaseCommandModuleCustom + [Command("random"), Aliases("rng"), Hidden, Cooldown(1, 3, CooldownBucketType.Channel)] + [Description("Provides random stuff")] + public async Task RandomShit(CommandContext ctx, string stuff) + { + stuff = stuff.ToLowerInvariant(); + switch (stuff) { - [Command("much"), Aliases("many")] - [Description("Provides advanced clairvoyance services to predict the exact amount of anything that could be measured")] - public async Task Much(CommandContext ctx, [RemainingText, Description("much or many ")] string ofWhat = "") + case "game": + case "serial": + case "productcode": + case "product code": { - var question = ofWhat.Trim().TrimEnd('?').ToLowerInvariant().StripInvisibleAndDiacritics().ToCanonicalForm(); - var prefix = DateTime.UtcNow.ToString("yyyyMMddHH"); - var crng = new Random((prefix + question).GetHashCode()); - if (crng.NextDouble() < 0.0001) - await ctx.RespondAsync($"🔮 My psychic powers tell me the answer should be **3.50** 🔮").ConfigureAwait(false); - else - await ctx.RespondAsync($"🔮 My psychic powers tell me the answer should be **{crng.Next(100) + 1}** 🔮").ConfigureAwait(false); - } - } - - [Command("rate"), Cooldown(20, 60, CooldownBucketType.Channel)] - [Description("Gives a ~~random~~ expert judgment on the matter at hand")] - public async Task Rate(CommandContext ctx, [RemainingText, Description("Something to rate")] string whatever = "") - { - try - { - var funMult = DateTime.UtcNow.Month == 4 && DateTime.UtcNow.Day == 1 ? 100 : Config.FunMultiplier; - var choices = RateAnswers; - var choiceFlags = new HashSet(); - whatever = whatever.ToLowerInvariant().StripInvisibleAndDiacritics(); - var originalWhatever = whatever; - var matches = Instead.Matches(whatever); - if (matches.Any()) + await using var db = new ThumbnailDb(); + var count = await db.Thumbnail.CountAsync().ConfigureAwait(false); + if (count == 0) { - var insteadWhatever = matches.Last().Groups["instead"].Value.TrimEager(); - if (!string.IsNullOrEmpty(insteadWhatever)) - whatever = insteadWhatever; + await ctx.Channel.SendMessageAsync("Sorry, I have no information about a single game yet").ConfigureAwait(false); + return; } - foreach (var attachment in ctx.Message.Attachments) - whatever += $" {attachment.FileSize}"; - var nekoUser = await ctx.Client.GetUserAsync(272032356922032139ul).ConfigureAwait(false); - var nekoMember = ctx.Client.GetMember(nekoUser); - var nekoMatch = new HashSet(new[] {nekoUser.Id.ToString(), nekoUser.Username, nekoMember?.DisplayName ?? "neko", "neko", "nekotekina",}); - var kdUser = await ctx.Client.GetUserAsync(272631898877198337ul).ConfigureAwait(false); - var kdMember = ctx.Client.GetMember(kdUser); - var kdMatch = new HashSet(new[] {kdUser.Id.ToString(), kdUser.Username, kdMember?.DisplayName ?? "kd-11", "kd", "kd-11", "kd11", }); - var botUser = ctx.Client.CurrentUser; - var botMember = ctx.Client.GetMember(botUser); - var botMatch = new HashSet(new[] {botUser.Id.ToString(), botUser.Username, botMember?.DisplayName ?? "RPCS3 bot", "yourself", "urself", "yoself",}); - - var prefix = DateTime.UtcNow.ToString("yyyyMMddHH"); - var words = whatever.Split(Separators); - var result = new StringBuilder(); - for (var i = 0; i < words.Length; i++) + var tmpRng = new Random().Next(count); + var productCode = await db.Thumbnail.Skip(tmpRng).Take(1).FirstOrDefaultAsync().ConfigureAwait(false); + if (productCode == null) { - var word = words[i].TrimEager(); - var suffix = ""; - var tmp = word.TrimEnd(Suffixes); - if (tmp.Length != word.Length) - { - suffix = word[..tmp.Length]; - word = tmp; - } - tmp = word.TrimStart(Prefixes); - if (tmp.Length != word.Length) - { - result.Append(word[..^tmp.Length]); - word = tmp; - } - if (word.EndsWith("'s")) - { - suffix = "'s" + suffix; - word = word[..^2]; - } - - void MakeCustomRoleRating(DiscordMember? mem) - { - if (mem is null || choiceFlags.Contains('f')) - return; - - var roleList = mem.Roles.ToList(); - if (roleList.Count == 0) - return; - - var role = roleList[new Random((prefix + mem.Id).GetHashCode()).Next(roleList.Count)].Name?.ToLowerInvariant(); - if (string.IsNullOrEmpty(role)) - return; - - if (role.EndsWith('s')) - role = role[..^1]; - var article = Vowels.Contains(role[0]) ? "n" : ""; - choices = RateAnswers.Concat(Enumerable.Repeat($"Pretty fly for a{article} {role} guy", RateAnswers.Count * funMult / 20)).ToList(); - choiceFlags.Add('f'); - } - - var appended = false; - DiscordMember? member = null; - if (Me.Contains(word)) - { - member = ctx.Member; - word = ctx.Message.Author.Id.ToString(); - result.Append(word); - appended = true; - } - else if (word == "my") - { - result.Append(ctx.Message.Author.Id).Append("'s"); - appended = true; - } - else if (botMatch.Contains(word)) - { - word = ctx.Client.CurrentUser.Id.ToString(); - result.Append(word); - appended = true; - } - else if (Your.Contains(word)) - { - result.Append(ctx.Client.CurrentUser.Id).Append("'s"); - appended = true; - } - else if (word.StartsWith("actually") || word.StartsWith("nevermind") || word.StartsWith("nvm")) - { - result.Clear(); - appended = true; - } - if (member is null && i == 0 && await ctx.ResolveMemberAsync(word).ConfigureAwait(false) is DiscordMember m) - member = m; - if (member != null) - { - if (suffix.Length == 0) - MakeCustomRoleRating(member); - if (!appended) - { - result.Append(member.Id); - appended = true; - } - } - if (nekoMatch.Contains(word)) - { - if (i == 0 && suffix.Length == 0) - { - choices = RateAnswers.Concat(Enumerable.Repeat("Ugh", RateAnswers.Count * 3 * funMult)).ToList(); - MakeCustomRoleRating(nekoMember); - } - result.Append(nekoUser.Id); - appended = true; - } - if (kdMatch.Contains(word)) - { - if (i == 0 && suffix.Length == 0) - { - choices = RateAnswers.Concat(Enumerable.Repeat("RSX genius", RateAnswers.Count * 3 * funMult)).ToList(); - MakeCustomRoleRating(kdMember); - } - result.Append(kdUser.Id); - appended = true; - } - if (!appended) - result.Append(word); - result.Append(suffix).Append(' '); + await ctx.Channel.SendMessageAsync("Sorry, there's something with my brains today. Try again or something").ConfigureAwait(false); + return; } - whatever = result.ToString(); - var cutIdx = whatever.LastIndexOf("never mind", StringComparison.Ordinal); - if (cutIdx > -1) - whatever = whatever[cutIdx..]; - whatever = whatever.Replace("'s's", "'s").TrimStart(EveryTimable).Trim(); - if (whatever.StartsWith("rate ")) - whatever = whatever[("rate ".Length)..]; - if (originalWhatever == "sonic" || originalWhatever.Contains("sonic the")) - choices = RateAnswers - .Concat(Enumerable.Repeat("💩 out of 🦔", RateAnswers.Count * funMult)) - .Concat(Enumerable.Repeat("Sonic out of 🦔", funMult)) - .Concat(Enumerable.Repeat("Sonic out of 10", funMult)) - .ToList(); - if (string.IsNullOrEmpty(whatever)) - await ctx.Channel.SendMessageAsync("Rating nothing makes _**so much**_ sense, right?").ConfigureAwait(false); - else - { - var seed = (prefix + whatever).GetHashCode(StringComparison.CurrentCultureIgnoreCase); - var seededRng = new Random(seed); - var answer = choices[seededRng.Next(choices.Count)]; - var msgBuilder = new DiscordMessageBuilder() - .WithContent(answer) - .WithReply(ctx.Message.Id); - await ctx.RespondAsync(msgBuilder).ConfigureAwait(false); - } + await ProductCodeLookup.LookupAndPostProductCodeEmbedAsync(ctx.Client, ctx.Message, ctx.Channel, new() {productCode.ProductCode}).ConfigureAwait(false); + break; } - catch (Exception e) - { - Config.Log.Warn(e, $"Failed to rate {whatever}"); - } - } - - [Command("meme"), Aliases("memes"), Cooldown(1, 30, CooldownBucketType.Channel), Hidden] - [Description("No, memes are not implemented yet")] - public async Task Memes(CommandContext ctx, [RemainingText] string? _ = null) - { - var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); - var msgBuilder = new DiscordMessageBuilder() - .WithContent($"{ctx.User.Mention} congratulations, you're the meme"); - if (ch.Id == ctx.Channel.Id) - msgBuilder.WithReply(ctx.Message.Id); - await ch.SendMessageAsync(msgBuilder).ConfigureAwait(false); - } - - [Command("firmware"), Aliases("fw"), Cooldown(1, 10, CooldownBucketType.Channel)] - [Description("Checks for latest PS3 firmware version")] - public Task Firmware(CommandContext ctx) => Psn.Check.GetFirmwareAsync(ctx); - - [Command("compare"), Hidden] - [Description("Calculates the similarity metric of two phrases from 0 (completely different) to 1 (identical)")] - public Task Compare(CommandContext ctx, string strA, string strB) - { - var result = strA.GetFuzzyCoefficientCached(strB); - return ctx.Channel.SendMessageAsync($"Similarity score is {result:0.######}"); - } - - [Command("productcode"), Aliases("pci", "decode")] - [Description("Describe Playstation product code")] - public async Task ProductCode(CommandContext ctx, [RemainingText, Description("Product code such as BLUS12345 or SCES")] string productCode) - { - productCode = ProductCodeLookup.GetProductIds(productCode).FirstOrDefault() ?? productCode; - productCode = productCode.ToUpperInvariant(); - if (productCode.Length > 3) - { - var dsc = ProductCodeDecoder.Decode(productCode); - var info = string.Join('\n', dsc); - if (productCode.Length == 9) - { - var embed = await ctx.Client.LookupGameInfoAsync(productCode).ConfigureAwait(false); - embed.AddField("Product code info", info); - await ctx.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); - } - else - await ctx.Channel.SendMessageAsync(info).ConfigureAwait(false); - } - else - await ctx.ReactWithAsync(Config.Reactions.Failure, "Invalid product code").ConfigureAwait(false); + default: + await Roll(ctx, comment: stuff).ConfigureAwait(false); + break; } } -} + + [Command("8ball"), Cooldown(20, 60, CooldownBucketType.Channel)] + [Description("Provides a ~~random~~ objectively best answer to your question")] + public async Task EightBall(CommandContext ctx, [RemainingText, Description("A yes/no question")] string question) + { + question = question.ToLowerInvariant(); + if (question.StartsWith("when ")) + await When(ctx, question[5..]).ConfigureAwait(false); + else + { + string answer; + var pool = string.IsNullOrEmpty(question) ? EightBallSnarkyComments : EightBallAnswers; + lock (rng) answer = pool[rng.Next(pool.Count)]; + if (answer.StartsWith(':') && answer.EndsWith(':')) + answer = ctx.Client.GetEmoji(answer, "🔮"); + await ctx.RespondAsync(answer).ConfigureAwait(false); + } + } + + [Command("when"), Hidden, Cooldown(20, 60, CooldownBucketType.Channel)] + [Description("Provides advanced clairvoyance services to predict the time frame for specified event with maximum accuracy")] + public async Task When(CommandContext ctx, [RemainingText, Description("Something to happen")] string something = "") + { + var question = something.Trim().TrimEnd('?').ToLowerInvariant().StripInvisibleAndDiacritics().ToCanonicalForm(); + var prefix = DateTime.UtcNow.ToString("yyyyMMddHH"); + var crng = new Random((prefix + question).GetHashCode()); + var number = crng.Next(100) + 1; + var unit = EightBallTimeUnits[crng.Next(EightBallTimeUnits.Count)]; + if (number > 1) + { + if (unit.EndsWith("ry")) + unit = unit[..^1] + "ie"; + unit += "s"; + if (unit == "millenniums") + unit = "millennia"; + } + var willWont = crng.NextDouble() < 0.5 ? "will" : "won't"; + await ctx.RespondAsync($"🔮 My psychic powers tell me it {willWont} happen in the next **{number} {unit}** 🔮").ConfigureAwait(false); + } + + [Group("how"), Hidden, Cooldown(20, 60, CooldownBucketType.Channel)] + [Description("Provides advanced clairvoyance services to predict the exact amount of anything that could be measured")] + public class How: BaseCommandModuleCustom + { + [Command("much"), Aliases("many")] + [Description("Provides advanced clairvoyance services to predict the exact amount of anything that could be measured")] + public async Task Much(CommandContext ctx, [RemainingText, Description("much or many ")] string ofWhat = "") + { + var question = ofWhat.Trim().TrimEnd('?').ToLowerInvariant().StripInvisibleAndDiacritics().ToCanonicalForm(); + var prefix = DateTime.UtcNow.ToString("yyyyMMddHH"); + var crng = new Random((prefix + question).GetHashCode()); + if (crng.NextDouble() < 0.0001) + await ctx.RespondAsync($"🔮 My psychic powers tell me the answer should be **3.50** 🔮").ConfigureAwait(false); + else + await ctx.RespondAsync($"🔮 My psychic powers tell me the answer should be **{crng.Next(100) + 1}** 🔮").ConfigureAwait(false); + } + } + + [Command("rate"), Cooldown(20, 60, CooldownBucketType.Channel)] + [Description("Gives a ~~random~~ expert judgment on the matter at hand")] + public async Task Rate(CommandContext ctx, [RemainingText, Description("Something to rate")] string whatever = "") + { + try + { + var funMult = DateTime.UtcNow.Month == 4 && DateTime.UtcNow.Day == 1 ? 100 : Config.FunMultiplier; + var choices = RateAnswers; + var choiceFlags = new HashSet(); + whatever = whatever.ToLowerInvariant().StripInvisibleAndDiacritics(); + var originalWhatever = whatever; + var matches = Instead.Matches(whatever); + if (matches.Any()) + { + var insteadWhatever = matches.Last().Groups["instead"].Value.TrimEager(); + if (!string.IsNullOrEmpty(insteadWhatever)) + whatever = insteadWhatever; + } + foreach (var attachment in ctx.Message.Attachments) + whatever += $" {attachment.FileSize}"; + + var nekoUser = await ctx.Client.GetUserAsync(272032356922032139ul).ConfigureAwait(false); + var nekoMember = ctx.Client.GetMember(nekoUser); + var nekoMatch = new HashSet(new[] {nekoUser.Id.ToString(), nekoUser.Username, nekoMember?.DisplayName ?? "neko", "neko", "nekotekina",}); + var kdUser = await ctx.Client.GetUserAsync(272631898877198337ul).ConfigureAwait(false); + var kdMember = ctx.Client.GetMember(kdUser); + var kdMatch = new HashSet(new[] {kdUser.Id.ToString(), kdUser.Username, kdMember?.DisplayName ?? "kd-11", "kd", "kd-11", "kd11", }); + var botUser = ctx.Client.CurrentUser; + var botMember = ctx.Client.GetMember(botUser); + var botMatch = new HashSet(new[] {botUser.Id.ToString(), botUser.Username, botMember?.DisplayName ?? "RPCS3 bot", "yourself", "urself", "yoself",}); + + var prefix = DateTime.UtcNow.ToString("yyyyMMddHH"); + var words = whatever.Split(Separators); + var result = new StringBuilder(); + for (var i = 0; i < words.Length; i++) + { + var word = words[i].TrimEager(); + var suffix = ""; + var tmp = word.TrimEnd(Suffixes); + if (tmp.Length != word.Length) + { + suffix = word[..tmp.Length]; + word = tmp; + } + tmp = word.TrimStart(Prefixes); + if (tmp.Length != word.Length) + { + result.Append(word[..^tmp.Length]); + word = tmp; + } + if (word.EndsWith("'s")) + { + suffix = "'s" + suffix; + word = word[..^2]; + } + + void MakeCustomRoleRating(DiscordMember? mem) + { + if (mem is null || choiceFlags.Contains('f')) + return; + + var roleList = mem.Roles.ToList(); + if (roleList.Count == 0) + return; + + var role = roleList[new Random((prefix + mem.Id).GetHashCode()).Next(roleList.Count)].Name?.ToLowerInvariant(); + if (string.IsNullOrEmpty(role)) + return; + + if (role.EndsWith('s')) + role = role[..^1]; + var article = Vowels.Contains(role[0]) ? "n" : ""; + choices = RateAnswers.Concat(Enumerable.Repeat($"Pretty fly for a{article} {role} guy", RateAnswers.Count * funMult / 20)).ToList(); + choiceFlags.Add('f'); + } + + var appended = false; + DiscordMember? member = null; + if (Me.Contains(word)) + { + member = ctx.Member; + word = ctx.Message.Author.Id.ToString(); + result.Append(word); + appended = true; + } + else if (word == "my") + { + result.Append(ctx.Message.Author.Id).Append("'s"); + appended = true; + } + else if (botMatch.Contains(word)) + { + word = ctx.Client.CurrentUser.Id.ToString(); + result.Append(word); + appended = true; + } + else if (Your.Contains(word)) + { + result.Append(ctx.Client.CurrentUser.Id).Append("'s"); + appended = true; + } + else if (word.StartsWith("actually") || word.StartsWith("nevermind") || word.StartsWith("nvm")) + { + result.Clear(); + appended = true; + } + if (member is null && i == 0 && await ctx.ResolveMemberAsync(word).ConfigureAwait(false) is DiscordMember m) + member = m; + if (member != null) + { + if (suffix.Length == 0) + MakeCustomRoleRating(member); + if (!appended) + { + result.Append(member.Id); + appended = true; + } + } + if (nekoMatch.Contains(word)) + { + if (i == 0 && suffix.Length == 0) + { + choices = RateAnswers.Concat(Enumerable.Repeat("Ugh", RateAnswers.Count * 3 * funMult)).ToList(); + MakeCustomRoleRating(nekoMember); + } + result.Append(nekoUser.Id); + appended = true; + } + if (kdMatch.Contains(word)) + { + if (i == 0 && suffix.Length == 0) + { + choices = RateAnswers.Concat(Enumerable.Repeat("RSX genius", RateAnswers.Count * 3 * funMult)).ToList(); + MakeCustomRoleRating(kdMember); + } + result.Append(kdUser.Id); + appended = true; + } + if (!appended) + result.Append(word); + result.Append(suffix).Append(' '); + } + whatever = result.ToString(); + var cutIdx = whatever.LastIndexOf("never mind", StringComparison.Ordinal); + if (cutIdx > -1) + whatever = whatever[cutIdx..]; + whatever = whatever.Replace("'s's", "'s").TrimStart(EveryTimable).Trim(); + if (whatever.StartsWith("rate ")) + whatever = whatever[("rate ".Length)..]; + if (originalWhatever == "sonic" || originalWhatever.Contains("sonic the")) + choices = RateAnswers + .Concat(Enumerable.Repeat("💩 out of 🦔", RateAnswers.Count * funMult)) + .Concat(Enumerable.Repeat("Sonic out of 🦔", funMult)) + .Concat(Enumerable.Repeat("Sonic out of 10", funMult)) + .ToList(); + + if (string.IsNullOrEmpty(whatever)) + await ctx.Channel.SendMessageAsync("Rating nothing makes _**so much**_ sense, right?").ConfigureAwait(false); + else + { + var seed = (prefix + whatever).GetHashCode(StringComparison.CurrentCultureIgnoreCase); + var seededRng = new Random(seed); + var answer = choices[seededRng.Next(choices.Count)]; + var msgBuilder = new DiscordMessageBuilder() + .WithContent(answer) + .WithReply(ctx.Message.Id); + await ctx.RespondAsync(msgBuilder).ConfigureAwait(false); + } + } + catch (Exception e) + { + Config.Log.Warn(e, $"Failed to rate {whatever}"); + } + } + + [Command("meme"), Aliases("memes"), Cooldown(1, 30, CooldownBucketType.Channel), Hidden] + [Description("No, memes are not implemented yet")] + public async Task Memes(CommandContext ctx, [RemainingText] string? _ = null) + { + var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); + var msgBuilder = new DiscordMessageBuilder() + .WithContent($"{ctx.User.Mention} congratulations, you're the meme"); + if (ch.Id == ctx.Channel.Id) + msgBuilder.WithReply(ctx.Message.Id); + await ch.SendMessageAsync(msgBuilder).ConfigureAwait(false); + } + + [Command("firmware"), Aliases("fw"), Cooldown(1, 10, CooldownBucketType.Channel)] + [Description("Checks for latest PS3 firmware version")] + public Task Firmware(CommandContext ctx) => Psn.Check.GetFirmwareAsync(ctx); + + [Command("compare"), Hidden] + [Description("Calculates the similarity metric of two phrases from 0 (completely different) to 1 (identical)")] + public Task Compare(CommandContext ctx, string strA, string strB) + { + var result = strA.GetFuzzyCoefficientCached(strB); + return ctx.Channel.SendMessageAsync($"Similarity score is {result:0.######}"); + } + + [Command("productcode"), Aliases("pci", "decode")] + [Description("Describe Playstation product code")] + public async Task ProductCode(CommandContext ctx, [RemainingText, Description("Product code such as BLUS12345 or SCES")] string productCode) + { + productCode = ProductCodeLookup.GetProductIds(productCode).FirstOrDefault() ?? productCode; + productCode = productCode.ToUpperInvariant(); + if (productCode.Length > 3) + { + var dsc = ProductCodeDecoder.Decode(productCode); + var info = string.Join('\n', dsc); + if (productCode.Length == 9) + { + var embed = await ctx.Client.LookupGameInfoAsync(productCode).ConfigureAwait(false); + embed.AddField("Product code info", info); + await ctx.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + } + else + await ctx.Channel.SendMessageAsync(info).ConfigureAwait(false); + } + else + await ctx.ReactWithAsync(Config.Reactions.Failure, "Invalid product code").ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Moderation.Audit.cs b/CompatBot/Commands/Moderation.Audit.cs index c8b7b54a..2f23667b 100644 --- a/CompatBot/Commands/Moderation.Audit.cs +++ b/CompatBot/Commands/Moderation.Audit.cs @@ -15,310 +15,309 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal sealed partial class Moderation { - internal sealed partial class Moderation + [Group("audit"), RequiresBotModRole] + [Description("Commands to audit server things")] + public sealed class Audit: BaseCommandModuleCustom { - [Group("audit"), RequiresBotModRole] - [Description("Commands to audit server things")] - public sealed class Audit: BaseCommandModuleCustom + public static readonly SemaphoreSlim CheckLock = new(1, 1); + + [Command("spoofing"), Aliases("impersonation"), RequireDirectMessage] + [Description("Checks every user on the server for name spoofing")] + public Task Spoofing(CommandContext ctx) { - public static readonly SemaphoreSlim CheckLock = new(1, 1); + SpoofingCheck(ctx); + return Task.CompletedTask; + } - [Command("spoofing"), Aliases("impersonation"), RequireDirectMessage] - [Description("Checks every user on the server for name spoofing")] - public Task Spoofing(CommandContext ctx) + [Command("members"), Aliases("users"), RequireDirectMessage] + [Description("Dumps server member information, including usernames, nicknames, and roles")] + public async Task Members(CommandContext ctx) + { + if (!await CheckLock.WaitAsync(0).ConfigureAwait(false)) { - SpoofingCheck(ctx); - return Task.CompletedTask; + await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); + return; } - [Command("members"), Aliases("users"), RequireDirectMessage] - [Description("Dumps server member information, including usernames, nicknames, and roles")] - public async Task Members(CommandContext ctx) + try { - if (!await CheckLock.WaitAsync(0).ConfigureAwait(false)) + await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + var members = GetMembers(ctx.Client); + await using var compressedResult = Config.MemoryStreamManager.GetStream(); + await using var memoryStream = Config.MemoryStreamManager.GetStream(); + await using var writer = new StreamWriter(memoryStream, new UTF8Encoding(false), 4096, true); + foreach (var member in members) + await writer.WriteLineAsync($"{member.Username}\t{member.Nickname}\t{member.JoinedAt:O}\t{(string.Join(',', member.Roles.Select(r => r.Name)))}").ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + memoryStream.Seek(0, SeekOrigin.Begin); + if (memoryStream.Length <= ctx.GetAttachmentSizeLimit()) { - await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("names.txt", memoryStream)).ConfigureAwait(false); return; } - try - { - await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - var members = GetMembers(ctx.Client); - await using var compressedResult = Config.MemoryStreamManager.GetStream(); - await using var memoryStream = Config.MemoryStreamManager.GetStream(); - await using var writer = new StreamWriter(memoryStream, new UTF8Encoding(false), 4096, true); - foreach (var member in members) - await writer.WriteLineAsync($"{member.Username}\t{member.Nickname}\t{member.JoinedAt:O}\t{(string.Join(',', member.Roles.Select(r => r.Name)))}").ConfigureAwait(false); - await writer.FlushAsync().ConfigureAwait(false); - memoryStream.Seek(0, SeekOrigin.Begin); - if (memoryStream.Length <= ctx.GetAttachmentSizeLimit()) + await using var gzip = new GZipStream(compressedResult, CompressionLevel.Optimal, true); + await memoryStream.CopyToAsync(gzip).ConfigureAwait(false); + await gzip.FlushAsync().ConfigureAwait(false); + compressedResult.Seek(0, SeekOrigin.Begin); + if (compressedResult.Length <= ctx.GetAttachmentSizeLimit()) + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("names.txt.gz", compressedResult)).ConfigureAwait(false); + else + await ctx.Channel.SendMessageAsync($"Dump is too large: {compressedResult.Length} bytes").ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to dump guild members"); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to dump guild members").ConfigureAwait(false); + } + finally + { + CheckLock.Release(); + await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + } + } + + [Command("raid")] + [Description("Kick known raiders")] + public async Task Raid(CommandContext ctx) + { + if (!await CheckLock.WaitAsync(0).ConfigureAwait(false)) + { + await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); + return; + } + + try + { + await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + var result = new StringBuilder("List of users:").AppendLine(); + var headerLength = result.Length; + var members = GetMembers(ctx.Client); + foreach (var member in members) + try { - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("names.txt", memoryStream)).ConfigureAwait(false); - return; - } + var displayName = member.DisplayName; + if (!UsernameRaidMonitor.NeedsKick(displayName)) + continue; - await using var gzip = new GZipStream(compressedResult, CompressionLevel.Optimal, true); - await memoryStream.CopyToAsync(gzip).ConfigureAwait(false); - await gzip.FlushAsync().ConfigureAwait(false); - compressedResult.Seek(0, SeekOrigin.Begin); - if (compressedResult.Length <= ctx.GetAttachmentSizeLimit()) - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("names.txt.gz", compressedResult)).ConfigureAwait(false); - else - await ctx.Channel.SendMessageAsync($"Dump is too large: {compressedResult.Length} bytes").ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to dump guild members"); - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to dump guild members").ConfigureAwait(false); - } - finally - { - CheckLock.Release(); - await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - } - } - - [Command("raid")] - [Description("Kick known raiders")] - public async Task Raid(CommandContext ctx) - { - if (!await CheckLock.WaitAsync(0).ConfigureAwait(false)) - { - await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); - return; - } - - try - { - await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - var result = new StringBuilder("List of users:").AppendLine(); - var headerLength = result.Length; - var members = GetMembers(ctx.Client); - foreach (var member in members) try { - var displayName = member.DisplayName; - if (!UsernameRaidMonitor.NeedsKick(displayName)) - continue; - - try - { - await member.RemoveAsync("Anti Raid").ConfigureAwait(false); - result.AppendLine($"{member.Username} have been automatically kicked"); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Failed to kick member {member.GetUsernameWithNickname()}"); - } + await member.RemoveAsync("Anti Raid").ConfigureAwait(false); + result.AppendLine($"{member.Username} have been automatically kicked"); } catch (Exception e) { - Config.Log.Warn(e, $"Failed to audit username for {member.Id}"); + Config.Log.Warn(e, $"Failed to kick member {member.GetUsernameWithNickname()}"); } - if (result.Length == headerLength) - result.AppendLine("No naughty users 🎉"); - await ctx.SendAutosplitMessageAsync(result, blockStart: "", blockEnd: "").ConfigureAwait(false); - } - catch (Exception e) - { - var msg = "Failed to check display names for raids for all guild members"; - Config.Log.Warn(e, msg); - await ctx.ReactWithAsync(Config.Reactions.Failure, msg).ConfigureAwait(false); - } - finally - { - CheckLock.Release(); - await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - } + } + catch (Exception e) + { + Config.Log.Warn(e, $"Failed to audit username for {member.Id}"); + } + if (result.Length == headerLength) + result.AppendLine("No naughty users 🎉"); + await ctx.SendAutosplitMessageAsync(result, blockStart: "", blockEnd: "").ConfigureAwait(false); + } + catch (Exception e) + { + var msg = "Failed to check display names for raids for all guild members"; + Config.Log.Warn(e, msg); + await ctx.ReactWithAsync(Config.Reactions.Failure, msg).ConfigureAwait(false); + } + finally + { + CheckLock.Release(); + await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + } + } + + + [Command("zalgo"), Aliases("diacritics")] + [Description("Checks every member's display name for discord and rule #7 requirements")] + public async Task Zalgo(CommandContext ctx) + { + if (!await CheckLock.WaitAsync(0).ConfigureAwait(false)) + { + await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); + return; } - - [Command("zalgo"), Aliases("diacritics")] - [Description("Checks every member's display name for discord and rule #7 requirements")] - public async Task Zalgo(CommandContext ctx) + try { - if (!await CheckLock.WaitAsync(0).ConfigureAwait(false)) - { - await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); - return; - } - - try - { - await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - var result = new StringBuilder("List of users who do not meet Rule #7 requirements:").AppendLine(); - var headerLength = result.Length; - var members = GetMembers(ctx.Client); - foreach (var member in members) - try - { - var displayName = member.DisplayName; - if (!UsernameZalgoMonitor.NeedsRename(displayName)) - continue; + await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + var result = new StringBuilder("List of users who do not meet Rule #7 requirements:").AppendLine(); + var headerLength = result.Length; + var members = GetMembers(ctx.Client); + foreach (var member in members) + try + { + var displayName = member.DisplayName; + if (!UsernameZalgoMonitor.NeedsRename(displayName)) + continue; - var nickname = UsernameZalgoMonitor.StripZalgo(displayName, member.Username, member.Id).Sanitize(); - try - { - await member.ModifyAsync(m => m.Nickname = nickname).ConfigureAwait(false); - result.AppendLine($"{member.Mention} have been automatically renamed from {displayName} to {nickname} according Rule #7"); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Failed to rename member {member.GetUsernameWithNickname()}"); - result.AppendLine($"{member.Mention} please change your nickname according to Rule #7 (suggestion: {nickname})"); - } + var nickname = UsernameZalgoMonitor.StripZalgo(displayName, member.Username, member.Id).Sanitize(); + try + { + await member.ModifyAsync(m => m.Nickname = nickname).ConfigureAwait(false); + result.AppendLine($"{member.Mention} have been automatically renamed from {displayName} to {nickname} according Rule #7"); } catch (Exception e) { - Config.Log.Warn(e, $"Failed to audit username for {member.Id}"); + Config.Log.Warn(e, $"Failed to rename member {member.GetUsernameWithNickname()}"); + result.AppendLine($"{member.Mention} please change your nickname according to Rule #7 (suggestion: {nickname})"); } - if (result.Length == headerLength) - result.AppendLine("No naughty users 🎉"); - await ctx.SendAutosplitMessageAsync(result, blockStart: "", blockEnd: "").ConfigureAwait(false); - } - catch (Exception e) - { - var msg = "Failed to check display names for zalgo for all guild members"; - Config.Log.Warn(e, msg); - await ctx.ReactWithAsync(Config.Reactions.Failure, msg).ConfigureAwait(false); - } - finally - { - CheckLock.Release(); - await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - } + } + catch (Exception e) + { + Config.Log.Warn(e, $"Failed to audit username for {member.Id}"); + } + if (result.Length == headerLength) + result.AppendLine("No naughty users 🎉"); + await ctx.SendAutosplitMessageAsync(result, blockStart: "", blockEnd: "").ConfigureAwait(false); + } + catch (Exception e) + { + var msg = "Failed to check display names for zalgo for all guild members"; + Config.Log.Warn(e, msg); + await ctx.ReactWithAsync(Config.Reactions.Failure, msg).ConfigureAwait(false); + } + finally + { + CheckLock.Release(); + await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + } + } + + /* + [Command("locales"), Aliases("locale", "languages", "language", "lang", "loc")] + public async Task UserLocales(CommandContext ctx) + { + if (!CheckLock.Wait(0)) + { + await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); + return; } - /* - [Command("locales"), Aliases("locale", "languages", "language", "lang", "loc")] - public async Task UserLocales(CommandContext ctx) + try { - if (!CheckLock.Wait(0)) + await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + var members = GetMembers(ctx.Client); + var stats = new Dictionary(); + foreach (var m in members) { - await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); - return; - } - - try - { - await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - var members = GetMembers(ctx.Client); - var stats = new Dictionary(); - foreach (var m in members) - { - var loc = m.Locale ?? "Unknown"; - if (stats.ContainsKey(loc)) - stats[loc]++; - else - stats[loc] = 1; - } - var table = new AsciiTable( - new AsciiColumn("Locale"), - new AsciiColumn("Count", alignToRight: true), - new AsciiColumn("%", alignToRight: true) - ); - var total = stats.Values.Sum(); - foreach (var lang in stats.OrderByDescending(l => l.Value).ThenBy(l => l.Key)) - table.Add(lang.Key, lang.Value.ToString(), $"{100.0 * lang.Value / total:0.00}%"); - await ctx.SendAutosplitMessageAsync(new StringBuilder().AppendLine("Member locale stats:").Append(table)).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to get locale stats"); - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to get locale stats").ConfigureAwait(false); - } - finally - { - CheckLock.Release(); - await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - } - } - */ - - private static List GetMembers(DiscordClient client) - { - //owner -> white name - //newbs -> veterans - return client.Guilds.Select(g => g.Value.GetAllMembersAsync().ConfigureAwait(false)) - .SelectMany(l => l.GetAwaiter().GetResult()) - .OrderByDescending(m => m.Hierarchy) - .ThenByDescending(m => m.JoinedAt) - .ToList(); - } - - private static async void SpoofingCheck(CommandContext ctx) - { - if (!CheckLock.Wait(0)) - { - await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); - return; - } - - try - { - await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - var members = GetMembers(ctx.Client); - if (members.Count < 2) - return; - - var result = new StringBuilder("List of potential impersonators → victims:").AppendLine(); - var headerLength = result.Length; - var checkedMembers = new List(members.Count) {members[0]}; - for (var i = 1; i < members.Count; i++) - { - var member = members[i]; - var victims = UsernameSpoofMonitor.GetPotentialVictims(ctx.Client, member, true, true, checkedMembers); - if (victims.Any()) - result.Append(member.GetMentionWithNickname()).Append(" → ").AppendLine(string.Join(", ", victims.Select(m => m.GetMentionWithNickname()))); - checkedMembers.Add(member); - } - - await using var compressedStream = Config.MemoryStreamManager.GetStream(); - await using var uncompressedStream = Config.MemoryStreamManager.GetStream(); - await using (var writer = new StreamWriter(uncompressedStream, new UTF8Encoding(false), 4096, true)) - { - await writer.WriteAsync(result.ToString()).ConfigureAwait(false); - await writer.FlushAsync().ConfigureAwait(false); - } - uncompressedStream.Seek(0, SeekOrigin.Begin); - if (result.Length <= headerLength) - { - await ctx.Channel.SendMessageAsync("No potential name spoofing was detected").ConfigureAwait(false); - return; - } - - if (uncompressedStream.Length <= ctx.GetAttachmentSizeLimit()) - { - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("spoofing_check_results.txt", uncompressedStream)).ConfigureAwait(false); - return; - } - - await using (var gzip = new GZipStream(compressedStream, CompressionLevel.Optimal, true)) - { - await uncompressedStream.CopyToAsync(gzip).ConfigureAwait(false); - gzip.Flush(); - } - compressedStream.Seek(0, SeekOrigin.Begin); - if (compressedStream.Length <= ctx.GetAttachmentSizeLimit()) - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("spoofing_check_results.txt.gz", compressedStream)).ConfigureAwait(false); + var loc = m.Locale ?? "Unknown"; + if (stats.ContainsKey(loc)) + stats[loc]++; else - await ctx.Channel.SendMessageAsync($"Dump is too large: {compressedStream.Length} bytes").ConfigureAwait(false); + stats[loc] = 1; } - catch (Exception e) + var table = new AsciiTable( + new AsciiColumn("Locale"), + new AsciiColumn("Count", alignToRight: true), + new AsciiColumn("%", alignToRight: true) + ); + var total = stats.Values.Sum(); + foreach (var lang in stats.OrderByDescending(l => l.Value).ThenBy(l => l.Key)) + table.Add(lang.Key, lang.Value.ToString(), $"{100.0 * lang.Value / total:0.00}%"); + await ctx.SendAutosplitMessageAsync(new StringBuilder().AppendLine("Member locale stats:").Append(table)).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to get locale stats"); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to get locale stats").ConfigureAwait(false); + } + finally + { + CheckLock.Release(); + await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + } + } + */ + + private static List GetMembers(DiscordClient client) + { + //owner -> white name + //newbs -> veterans + return client.Guilds.Select(g => g.Value.GetAllMembersAsync().ConfigureAwait(false)) + .SelectMany(l => l.GetAwaiter().GetResult()) + .OrderByDescending(m => m.Hierarchy) + .ThenByDescending(m => m.JoinedAt) + .ToList(); + } + + private static async void SpoofingCheck(CommandContext ctx) + { + if (!CheckLock.Wait(0)) + { + await ctx.Channel.SendMessageAsync("Another check is already in progress").ConfigureAwait(false); + return; + } + + try + { + await ctx.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + var members = GetMembers(ctx.Client); + if (members.Count < 2) + return; + + var result = new StringBuilder("List of potential impersonators → victims:").AppendLine(); + var headerLength = result.Length; + var checkedMembers = new List(members.Count) {members[0]}; + for (var i = 1; i < members.Count; i++) { - Config.Log.Error(e); - //should be extra careful, as async void will run on a thread pull, and will terminate the whole application with an uncaught exception - try { await ctx.ReactWithAsync(Config.Reactions.Failure, "(X_X)").ConfigureAwait(false); } catch { } + var member = members[i]; + var victims = UsernameSpoofMonitor.GetPotentialVictims(ctx.Client, member, true, true, checkedMembers); + if (victims.Any()) + result.Append(member.GetMentionWithNickname()).Append(" → ").AppendLine(string.Join(", ", victims.Select(m => m.GetMentionWithNickname()))); + checkedMembers.Add(member); } - finally + + await using var compressedStream = Config.MemoryStreamManager.GetStream(); + await using var uncompressedStream = Config.MemoryStreamManager.GetStream(); + await using (var writer = new StreamWriter(uncompressedStream, new UTF8Encoding(false), 4096, true)) { - CheckLock.Release(); - await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + await writer.WriteAsync(result.ToString()).ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); } + uncompressedStream.Seek(0, SeekOrigin.Begin); + if (result.Length <= headerLength) + { + await ctx.Channel.SendMessageAsync("No potential name spoofing was detected").ConfigureAwait(false); + return; + } + + if (uncompressedStream.Length <= ctx.GetAttachmentSizeLimit()) + { + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("spoofing_check_results.txt", uncompressedStream)).ConfigureAwait(false); + return; + } + + await using (var gzip = new GZipStream(compressedStream, CompressionLevel.Optimal, true)) + { + await uncompressedStream.CopyToAsync(gzip).ConfigureAwait(false); + gzip.Flush(); + } + compressedStream.Seek(0, SeekOrigin.Begin); + if (compressedStream.Length <= ctx.GetAttachmentSizeLimit()) + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile("spoofing_check_results.txt.gz", compressedStream)).ConfigureAwait(false); + else + await ctx.Channel.SendMessageAsync($"Dump is too large: {compressedStream.Length} bytes").ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e); + //should be extra careful, as async void will run on a thread pull, and will terminate the whole application with an uncaught exception + try { await ctx.ReactWithAsync(Config.Reactions.Failure, "(X_X)").ConfigureAwait(false); } catch { } + } + finally + { + CheckLock.Release(); + await ctx.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); } } } -} +} \ No newline at end of file diff --git a/CompatBot/Commands/Moderation.cs b/CompatBot/Commands/Moderation.cs index 7ee19dc9..1b87c42b 100644 --- a/CompatBot/Commands/Moderation.cs +++ b/CompatBot/Commands/Moderation.cs @@ -8,166 +8,165 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal sealed partial class Moderation: BaseCommandModuleCustom { - internal sealed partial class Moderation: BaseCommandModuleCustom + [Command("report"), RequiresWhitelistedRole] + [Description("Adds specified message to the moderation queue")] + public async Task Report(CommandContext ctx, [Description("Message ID from current channel to report")] ulong messageId, [RemainingText, Description("Optional report comment")] string? comment = null) { - [Command("report"), RequiresWhitelistedRole] - [Description("Adds specified message to the moderation queue")] - public async Task Report(CommandContext ctx, [Description("Message ID from current channel to report")] ulong messageId, [RemainingText, Description("Optional report comment")] string? comment = null) + try { - try - { - var msg = await ctx.Channel.GetMessageAsync(messageId).ConfigureAwait(false); - await ReportMessage(ctx, comment, msg); - } - catch (Exception) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to report the message").ConfigureAwait(false); - } + var msg = await ctx.Channel.GetMessageAsync(messageId).ConfigureAwait(false); + await ReportMessage(ctx, comment, msg); } - - [Command("report"), RequiresWhitelistedRole] - [Description("Adds specified message to the moderation queue")] - public async Task Report(CommandContext ctx, [Description("Message link to report")] string messageLink, [RemainingText, Description("Optional report comment")] string? comment = null) + catch (Exception) { - try - { - var msg = await ctx.GetMessageAsync(messageLink).ConfigureAwait(false); - if (msg is null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Can't find linked message").ConfigureAwait(false); - return; - } - - await ReportMessage(ctx, comment, msg); - } - catch (Exception) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to report the message").ConfigureAwait(false); - } - } - - [Command("analyze"), Aliases("reanalyze", "parse", "a")] - [Description("Make bot to look at the attached log again")] - public async Task Reanalyze(CommandContext ctx, [Description("Message ID from the same channel")]ulong messageId) - { - try - { - var msg = await ctx.Channel.GetMessageAsync(messageId).ConfigureAwait(false); - if (msg == null) - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - else - LogParsingHandler.EnqueueLogProcessing(ctx.Client, ctx.Channel, msg, ctx.Member, true, true); - } - catch - { - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - } - } - - [Command("analyze")] - public async Task Reanalyze(CommandContext ctx, [Description("Full message link")] string messageLink) - { - try - { - var msg = await ctx.GetMessageAsync(messageLink).ConfigureAwait(false); - if (msg == null) - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - else - LogParsingHandler.EnqueueLogProcessing(ctx.Client, ctx.Channel, msg, ctx.Member, true, true); - } - catch (Exception e) - { - Config.Log.Warn(e); - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - } - } - - - [Command("analyze")] - public async Task Reanalyze(CommandContext ctx) - { - try - { - if (ctx.Message.Attachments.Any()) - LogParsingHandler.EnqueueLogProcessing(ctx.Client, ctx.Channel, ctx.Message, ctx.Member, true, true); - else if (ctx.Message.ReferencedMessage is {} refMsg && refMsg.Attachments.Any()) - LogParsingHandler.EnqueueLogProcessing(ctx.Client, ctx.Channel, refMsg, ctx.Member, true, true); - - } - catch (Exception e) - { - Config.Log.Warn(e); - await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); - } - } - - [Command("badupdate"), Aliases("bad", "recall"), RequiresBotModRole] - [Description("Toggles new update announcement as being bad")] - public async Task BadUpdate(CommandContext ctx, [Description("Link to the update announcement")] string updateMessageLink) - { - var msg = await ctx.GetMessageAsync(updateMessageLink).ConfigureAwait(false); - var embed = msg?.Embeds?.FirstOrDefault(); - if (embed == null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Invalid update announcement link").ConfigureAwait(false); - return; - } - - await ToggleBadUpdateAnnouncementAsync(msg).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - } - - public static async Task ToggleBadUpdateAnnouncementAsync(DiscordMessage? message) - { - var embed = message?.Embeds?.FirstOrDefault(); - if (message is null || embed is null) - return; - - var result = new DiscordEmbedBuilder(embed); - const string warningTitle = "Warning!"; - if (embed.Color.Value.Value == Config.Colors.UpdateStatusGood.Value) - { - result = result.WithColor(Config.Colors.UpdateStatusBad); - result.ClearFields(); - var warned = false; - foreach (var f in embed.Fields) - { - if (!warned && f.Name.EndsWith("download")) - { - result.AddField(warningTitle, "This build is known to have severe problems, please avoid downloading."); - warned = true; - } - result.AddField(f.Name, f.Value, f.Inline); - } - } - else if (embed.Color.Value.Value == Config.Colors.UpdateStatusBad.Value) - { - result = result.WithColor(Config.Colors.UpdateStatusGood); - result.ClearFields(); - foreach (var f in embed.Fields) - { - if (f.Name == warningTitle) - continue; - - result.AddField(f.Name, f.Value, f.Inline); - } - } - await message.UpdateOrCreateMessageAsync(message.Channel, embed: result).ConfigureAwait(false); - } - - private static async Task ReportMessage(CommandContext ctx, string? comment, DiscordMessage msg) - { - if (msg.Reactions.Any(r => r.IsMe && r.Emoji == Config.Reactions.Moderated)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Already reported").ConfigureAwait(false); - return; - } - - await ctx.Client.ReportAsync("👀 Message report", msg, new[] {ctx.Client.GetMember(ctx.Message.Author)}, comment, ReportSeverity.Medium).ConfigureAwait(false); - await msg.ReactWithAsync(Config.Reactions.Moderated).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, "Message reported").ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to report the message").ConfigureAwait(false); } } -} + + [Command("report"), RequiresWhitelistedRole] + [Description("Adds specified message to the moderation queue")] + public async Task Report(CommandContext ctx, [Description("Message link to report")] string messageLink, [RemainingText, Description("Optional report comment")] string? comment = null) + { + try + { + var msg = await ctx.GetMessageAsync(messageLink).ConfigureAwait(false); + if (msg is null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Can't find linked message").ConfigureAwait(false); + return; + } + + await ReportMessage(ctx, comment, msg); + } + catch (Exception) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to report the message").ConfigureAwait(false); + } + } + + [Command("analyze"), Aliases("reanalyze", "parse", "a")] + [Description("Make bot to look at the attached log again")] + public async Task Reanalyze(CommandContext ctx, [Description("Message ID from the same channel")]ulong messageId) + { + try + { + var msg = await ctx.Channel.GetMessageAsync(messageId).ConfigureAwait(false); + if (msg == null) + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + else + LogParsingHandler.EnqueueLogProcessing(ctx.Client, ctx.Channel, msg, ctx.Member, true, true); + } + catch + { + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + } + } + + [Command("analyze")] + public async Task Reanalyze(CommandContext ctx, [Description("Full message link")] string messageLink) + { + try + { + var msg = await ctx.GetMessageAsync(messageLink).ConfigureAwait(false); + if (msg == null) + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + else + LogParsingHandler.EnqueueLogProcessing(ctx.Client, ctx.Channel, msg, ctx.Member, true, true); + } + catch (Exception e) + { + Config.Log.Warn(e); + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + } + } + + + [Command("analyze")] + public async Task Reanalyze(CommandContext ctx) + { + try + { + if (ctx.Message.Attachments.Any()) + LogParsingHandler.EnqueueLogProcessing(ctx.Client, ctx.Channel, ctx.Message, ctx.Member, true, true); + else if (ctx.Message.ReferencedMessage is {} refMsg && refMsg.Attachments.Any()) + LogParsingHandler.EnqueueLogProcessing(ctx.Client, ctx.Channel, refMsg, ctx.Member, true, true); + + } + catch (Exception e) + { + Config.Log.Warn(e); + await ctx.ReactWithAsync(Config.Reactions.Failure).ConfigureAwait(false); + } + } + + [Command("badupdate"), Aliases("bad", "recall"), RequiresBotModRole] + [Description("Toggles new update announcement as being bad")] + public async Task BadUpdate(CommandContext ctx, [Description("Link to the update announcement")] string updateMessageLink) + { + var msg = await ctx.GetMessageAsync(updateMessageLink).ConfigureAwait(false); + var embed = msg?.Embeds?.FirstOrDefault(); + if (embed == null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Invalid update announcement link").ConfigureAwait(false); + return; + } + + await ToggleBadUpdateAnnouncementAsync(msg).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + } + + public static async Task ToggleBadUpdateAnnouncementAsync(DiscordMessage? message) + { + var embed = message?.Embeds?.FirstOrDefault(); + if (message is null || embed is null) + return; + + var result = new DiscordEmbedBuilder(embed); + const string warningTitle = "Warning!"; + if (embed.Color.Value.Value == Config.Colors.UpdateStatusGood.Value) + { + result = result.WithColor(Config.Colors.UpdateStatusBad); + result.ClearFields(); + var warned = false; + foreach (var f in embed.Fields) + { + if (!warned && f.Name.EndsWith("download")) + { + result.AddField(warningTitle, "This build is known to have severe problems, please avoid downloading."); + warned = true; + } + result.AddField(f.Name, f.Value, f.Inline); + } + } + else if (embed.Color.Value.Value == Config.Colors.UpdateStatusBad.Value) + { + result = result.WithColor(Config.Colors.UpdateStatusGood); + result.ClearFields(); + foreach (var f in embed.Fields) + { + if (f.Name == warningTitle) + continue; + + result.AddField(f.Name, f.Value, f.Inline); + } + } + await message.UpdateOrCreateMessageAsync(message.Channel, embed: result).ConfigureAwait(false); + } + + private static async Task ReportMessage(CommandContext ctx, string? comment, DiscordMessage msg) + { + if (msg.Reactions.Any(r => r.IsMe && r.Emoji == Config.Reactions.Moderated)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Already reported").ConfigureAwait(false); + return; + } + + await ctx.Client.ReportAsync("👀 Message report", msg, new[] {ctx.Client.GetMember(ctx.Message.Author)}, comment, ReportSeverity.Medium).ConfigureAwait(false); + await msg.ReactWithAsync(Config.Reactions.Moderated).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, "Message reported").ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Pr.cs b/CompatBot/Commands/Pr.cs index aa9942f9..ceb5b841 100644 --- a/CompatBot/Commands/Pr.cs +++ b/CompatBot/Commands/Pr.cs @@ -14,292 +14,291 @@ using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; using TaskStatus = CirrusCiClient.Generated.TaskStatus; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("pr"), TriggersTyping] +[Description("Commands to list opened pull requests information")] +internal sealed class Pr: BaseCommandModuleCustom { - [Group("pr"), TriggersTyping] - [Description("Commands to list opened pull requests information")] - internal sealed class Pr: BaseCommandModuleCustom + private static readonly GithubClient.Client GithubClient = new(Config.GithubToken); + private static readonly CompatApiClient.Client CompatApiClient = new(); + + [GroupCommand] + public Task List(CommandContext ctx, [Description("Get information for specific PR number")] int pr) => LinkPrBuild(ctx.Client, ctx.Message, pr); + + [GroupCommand] + public async Task List(CommandContext ctx, [Description("Get information for PRs with specified text in description. First word might be an author"), RemainingText] string? searchStr = null) { - private static readonly GithubClient.Client GithubClient = new(Config.GithubToken); - private static readonly CompatApiClient.Client CompatApiClient = new(); - - [GroupCommand] - public Task List(CommandContext ctx, [Description("Get information for specific PR number")] int pr) => LinkPrBuild(ctx.Client, ctx.Message, pr); - - [GroupCommand] - public async Task List(CommandContext ctx, [Description("Get information for PRs with specified text in description. First word might be an author"), RemainingText] string? searchStr = null) + var openPrList = await GithubClient.GetOpenPrsAsync(Config.Cts.Token).ConfigureAwait(false); + if (openPrList == null) { - var openPrList = await GithubClient.GetOpenPrsAsync(Config.Cts.Token).ConfigureAwait(false); - if (openPrList == null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't retrieve open pull requests list, try again later").ConfigureAwait(false); - return; - } + await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't retrieve open pull requests list, try again later").ConfigureAwait(false); + return; + } - if (openPrList.Count == 0) - { - await ctx.Channel.SendMessageAsync("It looks like there are no open pull requests at the moment 🎉").ConfigureAwait(false); - return; - } + if (openPrList.Count == 0) + { + await ctx.Channel.SendMessageAsync("It looks like there are no open pull requests at the moment 🎉").ConfigureAwait(false); + return; + } - if (!string.IsNullOrEmpty(searchStr)) + if (!string.IsNullOrEmpty(searchStr)) + { + var filteredList = openPrList.Where( + pr => pr.Title?.Contains(searchStr, StringComparison.InvariantCultureIgnoreCase) is true + || pr.User?.Login?.Contains(searchStr, StringComparison.InvariantCultureIgnoreCase) is true + ).ToList(); + if (filteredList.Count == 0) { - var filteredList = openPrList.Where( - pr => pr.Title?.Contains(searchStr, StringComparison.InvariantCultureIgnoreCase) is true - || pr.User?.Login?.Contains(searchStr, StringComparison.InvariantCultureIgnoreCase) is true - ).ToList(); - if (filteredList.Count == 0) + var searchParts = searchStr.Split(' ', 2); + if (searchParts.Length == 2) { - var searchParts = searchStr.Split(' ', 2); - if (searchParts.Length == 2) - { - var author = searchParts[0].Trim(); - var substr = searchParts[1].Trim(); - openPrList = openPrList.Where( - pr => pr.User?.Login?.Contains(author, StringComparison.InvariantCultureIgnoreCase) is true - && pr.Title?.Contains(substr, StringComparison.InvariantCultureIgnoreCase) is true - ).ToList(); - } - else - openPrList = filteredList; + var author = searchParts[0].Trim(); + var substr = searchParts[1].Trim(); + openPrList = openPrList.Where( + pr => pr.User?.Login?.Contains(author, StringComparison.InvariantCultureIgnoreCase) is true + && pr.Title?.Contains(substr, StringComparison.InvariantCultureIgnoreCase) is true + ).ToList(); } else openPrList = filteredList; } - - if (openPrList.Count == 0) - { - await ctx.Channel.SendMessageAsync("No open pull requests were found for specified filter").ConfigureAwait(false); - return; - } - - if (openPrList.Count == 1) - { - await LinkPrBuild(ctx.Client, ctx.Message, openPrList[0].Number).ConfigureAwait(false); - return; - } - - var responseChannel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); - const int maxTitleLength = 80; - var maxNum = openPrList.Max(pr => pr.Number).ToString().Length + 1; - var maxAuthor = openPrList.Max(pr => (pr.User?.Login).GetVisibleLength()); - var maxTitle = Math.Min(openPrList.Max(pr => pr.Title.GetVisibleLength()), maxTitleLength); - var result = new StringBuilder($"There are {openPrList.Count} open pull requests:\n"); - foreach (var pr in openPrList) - result.Append('`').Append($"{("#" + pr.Number).PadLeft(maxNum)} by {pr.User?.Login?.PadRightVisible(maxAuthor)}: {pr.Title?.Trim(maxTitleLength).PadRightVisible(maxTitle)}".FixSpaces()).AppendLine($"` <{pr.HtmlUrl}>"); - await responseChannel.SendAutosplitMessageAsync(result, blockStart: null, blockEnd: null).ConfigureAwait(false); + else + openPrList = filteredList; } + + if (openPrList.Count == 0) + { + await ctx.Channel.SendMessageAsync("No open pull requests were found for specified filter").ConfigureAwait(false); + return; + } + + if (openPrList.Count == 1) + { + await LinkPrBuild(ctx.Client, ctx.Message, openPrList[0].Number).ConfigureAwait(false); + return; + } + + var responseChannel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); + const int maxTitleLength = 80; + var maxNum = openPrList.Max(pr => pr.Number).ToString().Length + 1; + var maxAuthor = openPrList.Max(pr => (pr.User?.Login).GetVisibleLength()); + var maxTitle = Math.Min(openPrList.Max(pr => pr.Title.GetVisibleLength()), maxTitleLength); + var result = new StringBuilder($"There are {openPrList.Count} open pull requests:\n"); + foreach (var pr in openPrList) + result.Append('`').Append($"{("#" + pr.Number).PadLeft(maxNum)} by {pr.User?.Login?.PadRightVisible(maxAuthor)}: {pr.Title?.Trim(maxTitleLength).PadRightVisible(maxTitle)}".FixSpaces()).AppendLine($"` <{pr.HtmlUrl}>"); + await responseChannel.SendAutosplitMessageAsync(result, blockStart: null, blockEnd: null).ConfigureAwait(false); + } #if DEBUG - [Command("stats"), RequiresBotModRole] - public async Task Stats(CommandContext ctx) - { - var azureClient = Config.GetAzureDevOpsClient(); - var duration = await azureClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false); - await ctx.Channel.SendMessageAsync( - $"Expected pipeline duration (using {duration.BuildCount} builds): \n" + - $"95%: {duration.Percentile95} ({duration.Percentile95.TotalMinutes})\n" + - $"90%: {duration.Percentile90} ({duration.Percentile90.TotalMinutes})\n" + - $"85%: {duration.Percentile85} ({duration.Percentile85.TotalMinutes})\n" + - $"80%: {duration.Percentile80} ({duration.Percentile80.TotalMinutes})\n" + - $"Avg: {duration.Mean} ({duration.Mean.TotalMinutes})\n" + - $"Dev: {duration.StdDev} ({duration.StdDev.TotalMinutes})" - ).ConfigureAwait(false); - } + [Command("stats"), RequiresBotModRole] + public async Task Stats(CommandContext ctx) + { + var azureClient = Config.GetAzureDevOpsClient(); + var duration = await azureClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false); + await ctx.Channel.SendMessageAsync( + $"Expected pipeline duration (using {duration.BuildCount} builds): \n" + + $"95%: {duration.Percentile95} ({duration.Percentile95.TotalMinutes})\n" + + $"90%: {duration.Percentile90} ({duration.Percentile90.TotalMinutes})\n" + + $"85%: {duration.Percentile85} ({duration.Percentile85.TotalMinutes})\n" + + $"80%: {duration.Percentile80} ({duration.Percentile80.TotalMinutes})\n" + + $"Avg: {duration.Mean} ({duration.Mean.TotalMinutes})\n" + + $"Dev: {duration.StdDev} ({duration.StdDev.TotalMinutes})" + ).ConfigureAwait(false); + } #endif - public static async Task LinkPrBuild(DiscordClient client, DiscordMessage message, int pr) + public static async Task LinkPrBuild(DiscordClient client, DiscordMessage message, int pr) + { + var prInfo = await GithubClient.GetPrInfoAsync(pr, Config.Cts.Token).ConfigureAwait(false); + if (prInfo is null or {Number: 0}) { - var prInfo = await GithubClient.GetPrInfoAsync(pr, Config.Cts.Token).ConfigureAwait(false); - if (prInfo is null or {Number: 0}) - { - await message.ReactWithAsync(Config.Reactions.Failure, prInfo?.Title ?? "PR not found").ConfigureAwait(false); - return; - } + await message.ReactWithAsync(Config.Reactions.Failure, prInfo?.Title ?? "PR not found").ConfigureAwait(false); + return; + } - var (state, _) = prInfo.GetState(); - var embed = prInfo.AsEmbed(); - var azureClient = Config.GetAzureDevOpsClient(); - if (state == "Open" || state == "Closed") - { - var windowsDownloadHeader = "Windows PR Build"; - var linuxDownloadHeader = "Linux PR Build"; - var macDownloadHeader = "Mac PR Build"; - string? windowsDownloadText = null; - string? linuxDownloadText = null; - string? macDownloadText = null; - string? buildTime = null; + var (state, _) = prInfo.GetState(); + var embed = prInfo.AsEmbed(); + var azureClient = Config.GetAzureDevOpsClient(); + if (state == "Open" || state == "Closed") + { + var windowsDownloadHeader = "Windows PR Build"; + var linuxDownloadHeader = "Linux PR Build"; + var macDownloadHeader = "Mac PR Build"; + string? windowsDownloadText = null; + string? linuxDownloadText = null; + string? macDownloadText = null; + string? buildTime = null; - if (prInfo.Head?.Sha is string commit) - try + if (prInfo.Head?.Sha is string commit) + try + { + windowsDownloadText = "⏳ Pending..."; + linuxDownloadText = "⏳ Pending..."; + macDownloadText = "⏳ Pending..."; + var latestBuild = await CirrusCi.GetPrBuildInfoAsync(commit, prInfo.MergedAt?.DateTime, pr, Config.Cts.Token).ConfigureAwait(false); + if (latestBuild == null) { - windowsDownloadText = "⏳ Pending..."; - linuxDownloadText = "⏳ Pending..."; - macDownloadText = "⏳ Pending..."; - var latestBuild = await CirrusCi.GetPrBuildInfoAsync(commit, prInfo.MergedAt?.DateTime, pr, Config.Cts.Token).ConfigureAwait(false); - if (latestBuild == null) + if (state == "Open") { - if (state == "Open") - { - embed.WithFooter($"Opened on {prInfo.CreatedAt:u} ({(DateTime.UtcNow - prInfo.CreatedAt).AsTimeDeltaDescription()} ago)"); - } - windowsDownloadText = null; - linuxDownloadText = null; - macDownloadText = null; + embed.WithFooter($"Opened on {prInfo.CreatedAt:u} ({(DateTime.UtcNow - prInfo.CreatedAt).AsTimeDeltaDescription()} ago)"); } - else - { - bool shouldHaveArtifacts = false; - - if ((latestBuild.WindowsBuild?.Status is TaskStatus.Completed - || latestBuild.LinuxBuild?.Status is TaskStatus.Completed - || latestBuild.MacBuild?.Status is TaskStatus.Completed) - && latestBuild.FinishTime.HasValue) - { - buildTime = $"Built on {latestBuild.FinishTime:u} ({(DateTime.UtcNow - latestBuild.FinishTime.Value).AsTimeDeltaDescription()} ago)"; - shouldHaveArtifacts = true; - } - - // Check for subtask errors (win/lin/mac) - if (latestBuild.WindowsBuild?.Status is TaskStatus.Aborted or TaskStatus.Failed or TaskStatus.Skipped) - { - windowsDownloadText = $"❌ {latestBuild.WindowsBuild?.Status}"; - } - if (latestBuild.LinuxBuild?.Status is TaskStatus.Aborted or TaskStatus.Failed or TaskStatus.Skipped) - { - linuxDownloadText = $"❌ {latestBuild.LinuxBuild?.Status}"; - } - if (latestBuild.MacBuild?.Status is TaskStatus.Aborted or TaskStatus.Failed or TaskStatus.Skipped) - { - macDownloadText = $"❌ {latestBuild.MacBuild?.Status}"; - } - - // Check estimated time for pending builds - if (latestBuild.WindowsBuild?.Status is TaskStatus.Executing - || latestBuild.LinuxBuild?.Status is TaskStatus.Executing - || latestBuild.MacBuild?.Status is TaskStatus.Executing) - { - var estimatedCompletionTime = latestBuild.StartTime + (await CirrusCi.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false)).Mean; - var estimatedTime = TimeSpan.FromMinutes(1); - if (estimatedCompletionTime > DateTime.UtcNow) - estimatedTime = estimatedCompletionTime - DateTime.UtcNow; - - if (latestBuild.WindowsBuild?.Status is TaskStatus.Executing) - { - windowsDownloadText = $"⏳ Pending in {estimatedTime.AsTimeDeltaDescription()}..."; - } - if (latestBuild.LinuxBuild?.Status is TaskStatus.Executing) - { - linuxDownloadText = $"⏳ Pending in {estimatedTime.AsTimeDeltaDescription()}..."; - } - if (latestBuild.MacBuild?.Status is TaskStatus.Executing) - { - macDownloadText = $"⏳ Pending in {estimatedTime.AsTimeDeltaDescription()}..."; - } - } - - // windows build - var name = latestBuild.WindowsBuild?.Filename ?? "Windows PR Build"; - name = name.Replace("rpcs3-", "").Replace("_win64", ""); - if (!string.IsNullOrEmpty(latestBuild.WindowsBuild?.DownloadLink)) - windowsDownloadText = $"[⏬ {name}]({latestBuild.WindowsBuild?.DownloadLink})"; - else if (shouldHaveArtifacts) - { - if (latestBuild.FinishTime.HasValue && (DateTime.UtcNow - latestBuild.FinishTime.Value).TotalDays > 30) - windowsDownloadText = "No longer available"; - } - - // linux build - name = latestBuild.LinuxBuild?.Filename ?? "Linux PR Build"; - name = name.Replace("rpcs3-", "").Replace("_linux64", ""); - if (!string.IsNullOrEmpty(latestBuild.LinuxBuild?.DownloadLink)) - linuxDownloadText = $"[⏬ {name}]({latestBuild.LinuxBuild?.DownloadLink})"; - else if (shouldHaveArtifacts) - { - if (latestBuild.FinishTime.HasValue && (DateTime.UtcNow - latestBuild.FinishTime.Value).TotalDays > 30) - linuxDownloadText = "No longer available"; - } - - // mac build - name = latestBuild.MacBuild?.Filename ?? "Mac PR Build"; - name = name.Replace("rpcs3-", "").Replace("_macos", ""); - if (!string.IsNullOrEmpty(latestBuild.MacBuild?.DownloadLink)) - macDownloadText = $"[⏬ {name}]({latestBuild.MacBuild?.DownloadLink})"; - else if (shouldHaveArtifacts) - { - if (latestBuild.FinishTime.HasValue && (DateTime.UtcNow - latestBuild.FinishTime.Value).TotalDays > 30) - macDownloadText = "No longer available"; - } - - // Neatify PR's with missing builders - if (latestBuild.WindowsBuild?.Status is null) - { - windowsDownloadText = null; - } - if (latestBuild.LinuxBuild?.Status is null) - { - linuxDownloadText = null; - } - if (latestBuild.MacBuild?.Status is null) - { - macDownloadText = null; - } - - - } - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to get CI build info"); - windowsDownloadText = null; // probably due to expired access token + windowsDownloadText = null; linuxDownloadText = null; macDownloadText = null; } - - if (!string.IsNullOrEmpty(windowsDownloadText)) - embed.AddField(windowsDownloadHeader, windowsDownloadText, true); - if (!string.IsNullOrEmpty(linuxDownloadText)) - embed.AddField(linuxDownloadHeader, linuxDownloadText, true); - if (!string.IsNullOrEmpty (macDownloadText)) - embed.AddField(macDownloadHeader, macDownloadText, true); - if (!string.IsNullOrEmpty(buildTime)) - embed.WithFooter(buildTime); - } - else if (state == "Merged" && azureClient is not null) - { - var mergeTime = prInfo.MergedAt.GetValueOrDefault(); - var now = DateTime.UtcNow; - var updateInfo = await CompatApiClient.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false); - if (updateInfo != null) - { - if (DateTime.TryParse(updateInfo.LatestBuild?.Datetime, out var masterBuildTime) && masterBuildTime.Ticks >= mergeTime.Ticks) - embed = await updateInfo.AsEmbedAsync(client, false, embed, prInfo).ConfigureAwait(false); else { - var waitTime = TimeSpan.FromMinutes(5); - var avgBuildTime = (await azureClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false)).Mean; - if (now < mergeTime + avgBuildTime) - waitTime = mergeTime + avgBuildTime - now; - embed.AddField("Latest master build", $"This pull request has been merged, and will be part of `master` very soon.\nPlease check again in {waitTime.AsTimeDeltaDescription()}."); + bool shouldHaveArtifacts = false; + + if ((latestBuild.WindowsBuild?.Status is TaskStatus.Completed + || latestBuild.LinuxBuild?.Status is TaskStatus.Completed + || latestBuild.MacBuild?.Status is TaskStatus.Completed) + && latestBuild.FinishTime.HasValue) + { + buildTime = $"Built on {latestBuild.FinishTime:u} ({(DateTime.UtcNow - latestBuild.FinishTime.Value).AsTimeDeltaDescription()} ago)"; + shouldHaveArtifacts = true; + } + + // Check for subtask errors (win/lin/mac) + if (latestBuild.WindowsBuild?.Status is TaskStatus.Aborted or TaskStatus.Failed or TaskStatus.Skipped) + { + windowsDownloadText = $"❌ {latestBuild.WindowsBuild?.Status}"; + } + if (latestBuild.LinuxBuild?.Status is TaskStatus.Aborted or TaskStatus.Failed or TaskStatus.Skipped) + { + linuxDownloadText = $"❌ {latestBuild.LinuxBuild?.Status}"; + } + if (latestBuild.MacBuild?.Status is TaskStatus.Aborted or TaskStatus.Failed or TaskStatus.Skipped) + { + macDownloadText = $"❌ {latestBuild.MacBuild?.Status}"; + } + + // Check estimated time for pending builds + if (latestBuild.WindowsBuild?.Status is TaskStatus.Executing + || latestBuild.LinuxBuild?.Status is TaskStatus.Executing + || latestBuild.MacBuild?.Status is TaskStatus.Executing) + { + var estimatedCompletionTime = latestBuild.StartTime + (await CirrusCi.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false)).Mean; + var estimatedTime = TimeSpan.FromMinutes(1); + if (estimatedCompletionTime > DateTime.UtcNow) + estimatedTime = estimatedCompletionTime - DateTime.UtcNow; + + if (latestBuild.WindowsBuild?.Status is TaskStatus.Executing) + { + windowsDownloadText = $"⏳ Pending in {estimatedTime.AsTimeDeltaDescription()}..."; + } + if (latestBuild.LinuxBuild?.Status is TaskStatus.Executing) + { + linuxDownloadText = $"⏳ Pending in {estimatedTime.AsTimeDeltaDescription()}..."; + } + if (latestBuild.MacBuild?.Status is TaskStatus.Executing) + { + macDownloadText = $"⏳ Pending in {estimatedTime.AsTimeDeltaDescription()}..."; + } + } + + // windows build + var name = latestBuild.WindowsBuild?.Filename ?? "Windows PR Build"; + name = name.Replace("rpcs3-", "").Replace("_win64", ""); + if (!string.IsNullOrEmpty(latestBuild.WindowsBuild?.DownloadLink)) + windowsDownloadText = $"[⏬ {name}]({latestBuild.WindowsBuild?.DownloadLink})"; + else if (shouldHaveArtifacts) + { + if (latestBuild.FinishTime.HasValue && (DateTime.UtcNow - latestBuild.FinishTime.Value).TotalDays > 30) + windowsDownloadText = "No longer available"; + } + + // linux build + name = latestBuild.LinuxBuild?.Filename ?? "Linux PR Build"; + name = name.Replace("rpcs3-", "").Replace("_linux64", ""); + if (!string.IsNullOrEmpty(latestBuild.LinuxBuild?.DownloadLink)) + linuxDownloadText = $"[⏬ {name}]({latestBuild.LinuxBuild?.DownloadLink})"; + else if (shouldHaveArtifacts) + { + if (latestBuild.FinishTime.HasValue && (DateTime.UtcNow - latestBuild.FinishTime.Value).TotalDays > 30) + linuxDownloadText = "No longer available"; + } + + // mac build + name = latestBuild.MacBuild?.Filename ?? "Mac PR Build"; + name = name.Replace("rpcs3-", "").Replace("_macos", ""); + if (!string.IsNullOrEmpty(latestBuild.MacBuild?.DownloadLink)) + macDownloadText = $"[⏬ {name}]({latestBuild.MacBuild?.DownloadLink})"; + else if (shouldHaveArtifacts) + { + if (latestBuild.FinishTime.HasValue && (DateTime.UtcNow - latestBuild.FinishTime.Value).TotalDays > 30) + macDownloadText = "No longer available"; + } + + // Neatify PR's with missing builders + if (latestBuild.WindowsBuild?.Status is null) + { + windowsDownloadText = null; + } + if (latestBuild.LinuxBuild?.Status is null) + { + linuxDownloadText = null; + } + if (latestBuild.MacBuild?.Status is null) + { + macDownloadText = null; + } + + } } - } - await message.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); - } + catch (Exception e) + { + Config.Log.Error(e, "Failed to get CI build info"); + windowsDownloadText = null; // probably due to expired access token + linuxDownloadText = null; + macDownloadText = null; + } - public static async Task LinkIssue(DiscordClient client, DiscordMessage message, int issue) + if (!string.IsNullOrEmpty(windowsDownloadText)) + embed.AddField(windowsDownloadHeader, windowsDownloadText, true); + if (!string.IsNullOrEmpty(linuxDownloadText)) + embed.AddField(linuxDownloadHeader, linuxDownloadText, true); + if (!string.IsNullOrEmpty (macDownloadText)) + embed.AddField(macDownloadHeader, macDownloadText, true); + if (!string.IsNullOrEmpty(buildTime)) + embed.WithFooter(buildTime); + } + else if (state == "Merged" && azureClient is not null) { - var issueInfo = await GithubClient.GetIssueInfoAsync(issue, Config.Cts.Token).ConfigureAwait(false); - if (issueInfo is null or {Number: 0}) - return; - - if (issueInfo.PullRequest != null) + var mergeTime = prInfo.MergedAt.GetValueOrDefault(); + var now = DateTime.UtcNow; + var updateInfo = await CompatApiClient.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false); + if (updateInfo != null) { - await LinkPrBuild(client, message, issue).ConfigureAwait(false); - return; + if (DateTime.TryParse(updateInfo.LatestBuild?.Datetime, out var masterBuildTime) && masterBuildTime.Ticks >= mergeTime.Ticks) + embed = await updateInfo.AsEmbedAsync(client, false, embed, prInfo).ConfigureAwait(false); + else + { + var waitTime = TimeSpan.FromMinutes(5); + var avgBuildTime = (await azureClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false)).Mean; + if (now < mergeTime + avgBuildTime) + waitTime = mergeTime + avgBuildTime - now; + embed.AddField("Latest master build", $"This pull request has been merged, and will be part of `master` very soon.\nPlease check again in {waitTime.AsTimeDeltaDescription()}."); + } } - - await message.Channel.SendMessageAsync(embed: issueInfo.AsEmbed()).ConfigureAwait(false); } + await message.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); } -} + + public static async Task LinkIssue(DiscordClient client, DiscordMessage message, int issue) + { + var issueInfo = await GithubClient.GetIssueInfoAsync(issue, Config.Cts.Token).ConfigureAwait(false); + if (issueInfo is null or {Number: 0}) + return; + + if (issueInfo.PullRequest != null) + { + await LinkPrBuild(client, message, issue).ConfigureAwait(false); + return; + } + + await message.Channel.SendMessageAsync(embed: issueInfo.AsEmbed()).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Psn.Check.cs b/CompatBot/Commands/Psn.Check.cs index 2e2eadc9..3a65169a 100644 --- a/CompatBot/Commands/Psn.Check.cs +++ b/CompatBot/Commands/Psn.Check.cs @@ -19,68 +19,50 @@ using DSharpPlus.Entities; using DSharpPlus.Interactivity.Extensions; using PsnClient.POCOs; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal sealed partial class Psn { - internal sealed partial class Psn + [Group("check")] + [Description("Commands to check for various stuff on PSN")] + public sealed class Check: BaseCommandModuleCustom { - [Group("check")] - [Description("Commands to check for various stuff on PSN")] - public sealed class Check: BaseCommandModuleCustom + private static string? latestFwVersion; + + [Command("updates"), Aliases("update"), LimitedToSpamChannel] + [Description("Checks if specified product has any updates")] + public async Task Updates(CommandContext ctx, [RemainingText, Description("Product code such as `BLUS12345`")] string productCode) { - private static string? latestFwVersion; - - [Command("updates"), Aliases("update"), LimitedToSpamChannel] - [Description("Checks if specified product has any updates")] - public async Task Updates(CommandContext ctx, [RemainingText, Description("Product code such as `BLUS12345`")] string productCode) + var providedId = productCode; + var id = ProductCodeLookup.GetProductIds(productCode).FirstOrDefault(); + var askForId = true; + DiscordMessage? botMsg = null; + if (string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(productCode)) { - var providedId = productCode; - var id = ProductCodeLookup.GetProductIds(productCode).FirstOrDefault(); - var askForId = true; - DiscordMessage? botMsg = null; - if (string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(productCode)) + var requestBuilder = RequestBuilder.Start().SetSearch(productCode); + var compatResult = CompatList.GetLocalCompatResult(requestBuilder) + .GetSortedList() + .Where(i => i.score > 0.8) + .Take(25) + .Select(i => i.code) + .Batch(5) + .ToList(); + if (compatResult.Count > 0) { - var requestBuilder = RequestBuilder.Start().SetSearch(productCode); - var compatResult = CompatList.GetLocalCompatResult(requestBuilder) - .GetSortedList() - .Where(i => i.score > 0.8) - .Take(25) - .Select(i => i.code) - .Batch(5) - .ToList(); - if (compatResult.Count > 0) - { - askForId = false; - var messageBuilder = new DiscordMessageBuilder() - .WithContent("Please select correct product code from the list or specify your own") - .WithReply(ctx.Message.Id); - foreach (var row in compatResult) - messageBuilder.AddComponents(row.Select(c => new DiscordButtonComponent(ButtonStyle.Secondary, "psn:check:updates:" + c, c))); - var interactivity = ctx.Client.GetInteractivity(); - botMsg = await botMsg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); - var reaction = await interactivity.WaitForMessageOrButtonAsync(botMsg, ctx.User, TimeSpan.FromMinutes(1)).ConfigureAwait(false); - if (reaction.reaction?.Id is {Length: >0} selectedId) - id = selectedId[^9..]; - else if (reaction.text?.Content is {Length: >0} customId - && !customId.StartsWith(Config.CommandPrefix) - && !customId.StartsWith(Config.AutoRemoveCommandPrefix)) - { - try{ await botMsg.DeleteAsync().ConfigureAwait(false); } catch {} - botMsg = null; - providedId = customId; - if (customId.Length > 8) - id = ProductCodeLookup.GetProductIds(customId).FirstOrDefault(); - } - } - } - if (string.IsNullOrEmpty(id) && askForId) - { - botMsg = await botMsg.UpdateOrCreateMessageAsync(ctx.Channel, "Please specify a valid product code (e.g. BLUS12345 or NPEB98765):").ConfigureAwait(false); - var interact = ctx.Client.GetInteractivity(); - var msg = await interact.WaitForMessageAsync(m => m.Author == ctx.User && m.Channel == ctx.Channel && !string.IsNullOrEmpty(m.Content)).ConfigureAwait(false); - - if (msg.Result?.Content is {Length: > 0} customId - && !customId.StartsWith(Config.CommandPrefix) - && !customId.StartsWith(Config.AutoRemoveCommandPrefix)) + askForId = false; + var messageBuilder = new DiscordMessageBuilder() + .WithContent("Please select correct product code from the list or specify your own") + .WithReply(ctx.Message.Id); + foreach (var row in compatResult) + messageBuilder.AddComponents(row.Select(c => new DiscordButtonComponent(ButtonStyle.Secondary, "psn:check:updates:" + c, c))); + var interactivity = ctx.Client.GetInteractivity(); + botMsg = await botMsg.UpdateOrCreateMessageAsync(ctx.Channel, messageBuilder).ConfigureAwait(false); + var reaction = await interactivity.WaitForMessageOrButtonAsync(botMsg, ctx.User, TimeSpan.FromMinutes(1)).ConfigureAwait(false); + if (reaction.reaction?.Id is {Length: >0} selectedId) + id = selectedId[^9..]; + else if (reaction.text?.Content is {Length: >0} customId + && !customId.StartsWith(Config.CommandPrefix) + && !customId.StartsWith(Config.AutoRemoveCommandPrefix)) { try{ await botMsg.DeleteAsync().ConfigureAwait(false); } catch {} botMsg = null; @@ -89,138 +71,155 @@ namespace CompatBot.Commands id = ProductCodeLookup.GetProductIds(customId).FirstOrDefault(); } } - if (string.IsNullOrEmpty(id)) - { - var msgBuilder = new DiscordMessageBuilder() - .WithContent($"`{providedId.Trim(10).Sanitize(replaceBackTicks: true)}` is not a valid product code") - .WithAllowedMentions(Config.AllowedMentions.Nothing); - await botMsg.UpdateOrCreateMessageAsync(ctx.Channel, msgBuilder).ConfigureAwait(false); - return; - } - List embeds; - try - { - var updateInfo = await TitleUpdateInfoProvider.GetAsync(id, Config.Cts.Token).ConfigureAwait(false); - embeds = await updateInfo.AsEmbedAsync(ctx.Client, id).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to get title update info"); - embeds = new() - { - new() - { - Color = Config.Colors.Maintenance, - Title = "Service is unavailable", - Description = "There was an error communicating with the service. Try again in a few minutes.", - } - }; - } + } + if (string.IsNullOrEmpty(id) && askForId) + { + botMsg = await botMsg.UpdateOrCreateMessageAsync(ctx.Channel, "Please specify a valid product code (e.g. BLUS12345 or NPEB98765):").ConfigureAwait(false); + var interact = ctx.Client.GetInteractivity(); + var msg = await interact.WaitForMessageAsync(m => m.Author == ctx.User && m.Channel == ctx.Channel && !string.IsNullOrEmpty(m.Content)).ConfigureAwait(false); - if (ctx.IsOnionLike() - && (embeds[0].Title.Contains("africa", StringComparison.InvariantCultureIgnoreCase) - || embeds[0].Title.Contains("afrika", StringComparison.InvariantCultureIgnoreCase))) + if (msg.Result?.Content is {Length: > 0} customId + && !customId.StartsWith(Config.CommandPrefix) + && !customId.StartsWith(Config.AutoRemoveCommandPrefix)) { - foreach (var embed in embeds) + try{ await botMsg.DeleteAsync().ConfigureAwait(false); } catch {} + botMsg = null; + providedId = customId; + if (customId.Length > 8) + id = ProductCodeLookup.GetProductIds(customId).FirstOrDefault(); + } + } + if (string.IsNullOrEmpty(id)) + { + var msgBuilder = new DiscordMessageBuilder() + .WithContent($"`{providedId.Trim(10).Sanitize(replaceBackTicks: true)}` is not a valid product code") + .WithAllowedMentions(Config.AllowedMentions.Nothing); + await botMsg.UpdateOrCreateMessageAsync(ctx.Channel, msgBuilder).ConfigureAwait(false); + return; + } + List embeds; + try + { + var updateInfo = await TitleUpdateInfoProvider.GetAsync(id, Config.Cts.Token).ConfigureAwait(false); + embeds = await updateInfo.AsEmbedAsync(ctx.Client, id).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to get title update info"); + embeds = new() + { + new() { - var newTitle = "(๑•ิཬ•ั๑)"; - var partStart = embed.Title.IndexOf(" [Part", StringComparison.Ordinal); - if (partStart > -1) - newTitle += embed.Title[partStart..]; - embed.Title = newTitle; - if (!string.IsNullOrEmpty(embed.Thumbnail?.Url)) - embed.WithThumbnail("https://cdn.discordapp.com/attachments/417347469521715210/516340151589535745/onionoff.png"); + Color = Config.Colors.Maintenance, + Title = "Service is unavailable", + Description = "There was an error communicating with the service. Try again in a few minutes.", } - var sqvat = ctx.Client.GetEmoji(":sqvat:", Config.Reactions.No)!; - await ctx.Message.ReactWithAsync(sqvat).ConfigureAwait(false); - } - if (embeds.Count > 1 || embeds[0].Fields.Count > 0) - embeds[^1] = embeds.Last().WithFooter("Note that you need to install ALL listed updates, one by one"); + }; + } - var resultMsgBuilder = new DiscordMessageBuilder() - .WithEmbed(embeds[0]) + if (ctx.IsOnionLike() + && (embeds[0].Title.Contains("africa", StringComparison.InvariantCultureIgnoreCase) + || embeds[0].Title.Contains("afrika", StringComparison.InvariantCultureIgnoreCase))) + { + foreach (var embed in embeds) + { + var newTitle = "(๑•ิཬ•ั๑)"; + var partStart = embed.Title.IndexOf(" [Part", StringComparison.Ordinal); + if (partStart > -1) + newTitle += embed.Title[partStart..]; + embed.Title = newTitle; + if (!string.IsNullOrEmpty(embed.Thumbnail?.Url)) + embed.WithThumbnail("https://cdn.discordapp.com/attachments/417347469521715210/516340151589535745/onionoff.png"); + } + var sqvat = ctx.Client.GetEmoji(":sqvat:", Config.Reactions.No)!; + await ctx.Message.ReactWithAsync(sqvat).ConfigureAwait(false); + } + if (embeds.Count > 1 || embeds[0].Fields.Count > 0) + embeds[^1] = embeds.Last().WithFooter("Note that you need to install ALL listed updates, one by one"); + + var resultMsgBuilder = new DiscordMessageBuilder() + .WithEmbed(embeds[0]) + .WithReply(ctx.Message.Id); + await botMsg.UpdateOrCreateMessageAsync(ctx.Channel, resultMsgBuilder).ConfigureAwait(false); + foreach (var embed in embeds.Skip(1)) + { + resultMsgBuilder = new DiscordMessageBuilder() + .WithEmbed(embed) .WithReply(ctx.Message.Id); - await botMsg.UpdateOrCreateMessageAsync(ctx.Channel, resultMsgBuilder).ConfigureAwait(false); - foreach (var embed in embeds.Skip(1)) - { - resultMsgBuilder = new DiscordMessageBuilder() - .WithEmbed(embed) - .WithReply(ctx.Message.Id); - await ctx.Channel.SendMessageAsync(resultMsgBuilder).ConfigureAwait(false); - } + await ctx.Channel.SendMessageAsync(resultMsgBuilder).ConfigureAwait(false); + } + } + + [Command("content"), Hidden] + [Description("Adds PSN content id to the scraping queue")] + public async Task Content(CommandContext ctx, [RemainingText, Description("Content IDs to scrape, such as `UP0006-NPUB30592_00-MONOPOLYPSNNA000`")] string contentIds) + { + if (string.IsNullOrEmpty(contentIds)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "No IDs were specified").ConfigureAwait(false); + return; } - [Command("content"), Hidden] - [Description("Adds PSN content id to the scraping queue")] - public async Task Content(CommandContext ctx, [RemainingText, Description("Content IDs to scrape, such as `UP0006-NPUB30592_00-MONOPOLYPSNNA000`")] string contentIds) + var matches = PsnScraper.ContentIdMatcher.Matches(contentIds.ToUpperInvariant()); + var itemsToCheck = matches.Select(m => m.Groups["content_id"].Value).ToList(); + if (itemsToCheck.Count == 0) { - if (string.IsNullOrEmpty(contentIds)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "No IDs were specified").ConfigureAwait(false); - return; - } - - var matches = PsnScraper.ContentIdMatcher.Matches(contentIds.ToUpperInvariant()); - var itemsToCheck = matches.Select(m => m.Groups["content_id"].Value).ToList(); - if (itemsToCheck.Count == 0) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "No IDs were specified").ConfigureAwait(false); - return; - } - - foreach (var id in itemsToCheck) - PsnScraper.CheckContentIdAsync(ctx, id, Config.Cts.Token); - - await ctx.ReactWithAsync(Config.Reactions.Success, $"Added {itemsToCheck.Count} ID{StringUtils.GetSuffix(itemsToCheck.Count)} to the scraping queue").ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Failure, "No IDs were specified").ConfigureAwait(false); + return; } - [Command("firmware"), Aliases("fw")] - [Cooldown(1, 10, CooldownBucketType.Channel)] - [Description("Checks for latest PS3 firmware version")] - public Task Firmware(CommandContext ctx) => GetFirmwareAsync(ctx); + foreach (var id in itemsToCheck) + PsnScraper.CheckContentIdAsync(ctx, id, Config.Cts.Token); - internal static async Task GetFirmwareAsync(CommandContext ctx) + await ctx.ReactWithAsync(Config.Reactions.Success, $"Added {itemsToCheck.Count} ID{StringUtils.GetSuffix(itemsToCheck.Count)} to the scraping queue").ConfigureAwait(false); + } + + [Command("firmware"), Aliases("fw")] + [Cooldown(1, 10, CooldownBucketType.Channel)] + [Description("Checks for latest PS3 firmware version")] + public Task Firmware(CommandContext ctx) => GetFirmwareAsync(ctx); + + internal static async Task GetFirmwareAsync(CommandContext ctx) + { + var fwList = await Client.GetHighestFwVersionAsync(Config.Cts.Token).ConfigureAwait(false); + var embed = fwList.ToEmbed(); + await ctx.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + } + + internal static async Task CheckFwUpdateForAnnouncementAsync(DiscordClient client, List? fwList = null) + { + fwList ??= await Client.GetHighestFwVersionAsync(Config.Cts.Token).ConfigureAwait(false); + if (fwList.Count == 0) + return; + + var newVersion = fwList[0].Version; + await using var db = new BotDb(); + var fwVersionState = db.BotState.FirstOrDefault(s => s.Key == "Latest-Firmware-Version"); + latestFwVersion ??= fwVersionState?.Value; + if (latestFwVersion is null + || (Version.TryParse(newVersion, out var newFw) + && Version.TryParse(latestFwVersion, out var oldFw) + && newFw > oldFw)) { - var fwList = await Client.GetHighestFwVersionAsync(Config.Cts.Token).ConfigureAwait(false); - var embed = fwList.ToEmbed(); - await ctx.Channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + var embed = fwList.ToEmbed().WithTitle("New PS3 Firmware Information"); + var announcementChannel = await client.GetChannelAsync(Config.BotChannelId).ConfigureAwait(false); + await announcementChannel.SendMessageAsync(embed: embed).ConfigureAwait(false); + latestFwVersion = newVersion; + if (fwVersionState == null) + await db.BotState.AddAsync(new() {Key = "Latest-Firmware-Version", Value = latestFwVersion}).ConfigureAwait(false); + else + fwVersionState.Value = latestFwVersion; + await db.SaveChangesAsync().ConfigureAwait(false); } + } - internal static async Task CheckFwUpdateForAnnouncementAsync(DiscordClient client, List? fwList = null) + internal static async Task MonitorFwUpdates(DiscordClient client, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) { - fwList ??= await Client.GetHighestFwVersionAsync(Config.Cts.Token).ConfigureAwait(false); - if (fwList.Count == 0) - return; - - var newVersion = fwList[0].Version; - await using var db = new BotDb(); - var fwVersionState = db.BotState.FirstOrDefault(s => s.Key == "Latest-Firmware-Version"); - latestFwVersion ??= fwVersionState?.Value; - if (latestFwVersion is null - || (Version.TryParse(newVersion, out var newFw) - && Version.TryParse(latestFwVersion, out var oldFw) - && newFw > oldFw)) - { - var embed = fwList.ToEmbed().WithTitle("New PS3 Firmware Information"); - var announcementChannel = await client.GetChannelAsync(Config.BotChannelId).ConfigureAwait(false); - await announcementChannel.SendMessageAsync(embed: embed).ConfigureAwait(false); - latestFwVersion = newVersion; - if (fwVersionState == null) - await db.BotState.AddAsync(new() {Key = "Latest-Firmware-Version", Value = latestFwVersion}).ConfigureAwait(false); - else - fwVersionState.Value = latestFwVersion; - await db.SaveChangesAsync().ConfigureAwait(false); - } - } - - internal static async Task MonitorFwUpdates(DiscordClient client, CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - await CheckFwUpdateForAnnouncementAsync(client).ConfigureAwait(false); - await Task.Delay(TimeSpan.FromHours(1), cancellationToken).ConfigureAwait(false); - } + await CheckFwUpdateForAnnouncementAsync(client).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromHours(1), cancellationToken).ConfigureAwait(false); } } } -} +} \ No newline at end of file diff --git a/CompatBot/Commands/Psn.cs b/CompatBot/Commands/Psn.cs index 7abb85f7..0cd056d8 100644 --- a/CompatBot/Commands/Psn.cs +++ b/CompatBot/Commands/Psn.cs @@ -9,70 +9,69 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using PsnClient; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("psn")] +[Description("Commands related to PSN metadata")] +internal sealed partial class Psn: BaseCommandModuleCustom { - [Group("psn")] - [Description("Commands related to PSN metadata")] - internal sealed partial class Psn: BaseCommandModuleCustom + private static readonly Client Client = new(); + + [Command("rename"), Aliases("setname", "settitle"), RequiresBotModRole] + [Description("Command to set or change game title for specific product code")] + public async Task Rename(CommandContext ctx, [Description("Product code such as BLUS12345")] string productCode, [RemainingText, Description("New game title to save in the database")] string title) { - private static readonly Client Client = new(); - - [Command("rename"), Aliases("setname", "settitle"), RequiresBotModRole] - [Description("Command to set or change game title for specific product code")] - public async Task Rename(CommandContext ctx, [Description("Product code such as BLUS12345")] string productCode, [RemainingText, Description("New game title to save in the database")] string title) + productCode = productCode.ToUpperInvariant(); + await using var db = new ThumbnailDb(); + var item = db.Thumbnail.FirstOrDefault(t => t.ProductCode == productCode); + if (item == null) + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown product code {productCode}", true).ConfigureAwait(false); + else { - productCode = productCode.ToUpperInvariant(); - await using var db = new ThumbnailDb(); - var item = db.Thumbnail.FirstOrDefault(t => t.ProductCode == productCode); - if (item == null) - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Unknown product code {productCode}", true).ConfigureAwait(false); - else - { - item.Name = title; - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, "Title updated successfully").ConfigureAwait(false); - } - } - - [Command("add"), RequiresBotModRole] - [Description("Add new product code with specified title to the bot database")] - public async Task Add(CommandContext ctx, [Description("Product code such as BLUS12345")] string contentId, [RemainingText, Description("New game title to save in the database")] string title) - { - contentId = contentId.ToUpperInvariant(); - var productCodeMatch = ProductCodeLookup.ProductCode.Match(contentId); - var contentIdMatch = PsnScraper.ContentIdMatcher.Match(contentId); - string productCode; - if (contentIdMatch.Success) - { - productCode = contentIdMatch.Groups["product_id"].Value; - } - else if (productCodeMatch.Success) - { - productCode = productCodeMatch.Groups["letters"].Value + productCodeMatch.Groups["numbers"].Value; - contentId = ""; - } - else - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Invalid content id", true).ConfigureAwait(false); - return; - } - - await using var db = new ThumbnailDb(); - var item = db.Thumbnail.FirstOrDefault(t => t.ProductCode == productCode); - if (item is null) - { - item = new Thumbnail - { - ProductCode = productCode, - ContentId = string.IsNullOrEmpty(contentId) ? null : contentId, - Name = title, - }; - await db.AddAsync(item).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, "Title added successfully").ConfigureAwait(false); - } - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"Product code {contentId} already exists", true).ConfigureAwait(false); + item.Name = title; + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, "Title updated successfully").ConfigureAwait(false); } } -} + + [Command("add"), RequiresBotModRole] + [Description("Add new product code with specified title to the bot database")] + public async Task Add(CommandContext ctx, [Description("Product code such as BLUS12345")] string contentId, [RemainingText, Description("New game title to save in the database")] string title) + { + contentId = contentId.ToUpperInvariant(); + var productCodeMatch = ProductCodeLookup.ProductCode.Match(contentId); + var contentIdMatch = PsnScraper.ContentIdMatcher.Match(contentId); + string productCode; + if (contentIdMatch.Success) + { + productCode = contentIdMatch.Groups["product_id"].Value; + } + else if (productCodeMatch.Success) + { + productCode = productCodeMatch.Groups["letters"].Value + productCodeMatch.Groups["numbers"].Value; + contentId = ""; + } + else + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Invalid content id", true).ConfigureAwait(false); + return; + } + + await using var db = new ThumbnailDb(); + var item = db.Thumbnail.FirstOrDefault(t => t.ProductCode == productCode); + if (item is null) + { + item = new Thumbnail + { + ProductCode = productCode, + ContentId = string.IsNullOrEmpty(contentId) ? null : contentId, + Name = title, + }; + await db.AddAsync(item).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, "Title added successfully").ConfigureAwait(false); + } + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"Product code {contentId} already exists", true).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/CompatBot/Commands/SlashTest.cs b/CompatBot/Commands/SlashTest.cs index 0f22a1f0..95aafca0 100644 --- a/CompatBot/Commands/SlashTest.cs +++ b/CompatBot/Commands/SlashTest.cs @@ -4,34 +4,33 @@ using DSharpPlus.Entities; using DSharpPlus.SlashCommands; using System.Threading.Tasks; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal sealed class SlashTest: ApplicationCommandModule { - internal sealed class SlashTest: ApplicationCommandModule - { - [SlashCommand("credits", "Author Credit")] - // TODO [Aliases("about")] - public async Task About(InteractionContext ctx) - { - var hcorion = ctx.Client.GetEmoji(":hcorion:", DiscordEmoji.FromUnicode("🍁")); - var clienthax = ctx.Client.GetEmoji(":gooseknife:", DiscordEmoji.FromUnicode("🐱")); - var embed = new DiscordEmbedBuilder + [SlashCommand("credits", "Author Credit")] + // TODO [Aliases("about")] + public async Task About(InteractionContext ctx) + { + var hcorion = ctx.Client.GetEmoji(":hcorion:", DiscordEmoji.FromUnicode("🍁")); + var clienthax = ctx.Client.GetEmoji(":gooseknife:", DiscordEmoji.FromUnicode("🐱")); + var embed = new DiscordEmbedBuilder { Title = "RPCS3 Compatibility Bot", Url = "https://github.com/RPCS3/discord-bot", Color = DiscordColor.Purple, }.AddField("Made by", - "💮 13xforever\n" + - "🇭🇷 Roberto Anić Banić aka nicba1010\n" + - $"{clienthax} clienthax\n" - ) - .AddField("People who ~~broke~~ helped test the bot", - "🐱 Juhn\n" + - $"{hcorion} hcorion\n" + - "🙃 TGE\n" + - "🍒 Maru\n" + - "♋ Tourghool"); - await ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().AddEmbed(embed.Build())); - } - + "💮 13xforever\n" + + "🇭🇷 Roberto Anić Banić aka nicba1010\n" + + $"{clienthax} clienthax\n" + ) + .AddField("People who ~~broke~~ helped test the bot", + "🐱 Juhn\n" + + $"{hcorion} hcorion\n" + + "🙃 TGE\n" + + "🍒 Maru\n" + + "♋ Tourghool"); + await ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().AddEmbed(embed.Build())); } -} + +} \ No newline at end of file diff --git a/CompatBot/Commands/Sudo.Bot.Configuration.cs b/CompatBot/Commands/Sudo.Bot.Configuration.cs index a4758726..99607156 100644 --- a/CompatBot/Commands/Sudo.Bot.Configuration.cs +++ b/CompatBot/Commands/Sudo.Bot.Configuration.cs @@ -9,75 +9,74 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal partial class Sudo { - internal partial class Sudo + public sealed partial class Bot { - public sealed partial class Bot + [Group("config"), RequiresBotSudoerRole] + [Description("Commands to set or clear bot configuration variables")] + public sealed class Configuration : BaseCommandModule { - [Group("config"), RequiresBotSudoerRole] - [Description("Commands to set or clear bot configuration variables")] - public sealed class Configuration : BaseCommandModule + [Command("list"), Aliases("show")] + [Description("Lists set variable names")] + public async Task List(CommandContext ctx) { - [Command("list"), Aliases("show")] - [Description("Lists set variable names")] - public async Task List(CommandContext ctx) + await using var db = new BotDb(); + var setVars = await db.BotState.AsNoTracking().Where(v => v.Key.StartsWith(SqlConfiguration.ConfigVarPrefix)).ToListAsync().ConfigureAwait(false); + if (setVars.Any()) { - await using var db = new BotDb(); - var setVars = await db.BotState.AsNoTracking().Where(v => v.Key.StartsWith(SqlConfiguration.ConfigVarPrefix)).ToListAsync().ConfigureAwait(false); - if (setVars.Any()) + var result = new StringBuilder("Set variables:").AppendLine(); + foreach (var v in setVars) { - var result = new StringBuilder("Set variables:").AppendLine(); - foreach (var v in setVars) - { #if DEBUG - result.Append(v.Key![SqlConfiguration.ConfigVarPrefix.Length ..]).Append(" = ").AppendLine(v.Value); + result.Append(v.Key![SqlConfiguration.ConfigVarPrefix.Length ..]).Append(" = ").AppendLine(v.Value); #else result.AppendLine(v.Key![(SqlConfiguration.ConfigVarPrefix.Length)..]); #endif - } - await ctx.Channel.SendMessageAsync(result.ToString()).ConfigureAwait(false); } - else - await ctx.Channel.SendMessageAsync("No variables were set yet").ConfigureAwait(false); + await ctx.Channel.SendMessageAsync(result.ToString()).ConfigureAwait(false); } + else + await ctx.Channel.SendMessageAsync("No variables were set yet").ConfigureAwait(false); + } - [Command("set")] - [Description("Sets configuration variable")] - public async Task Set(CommandContext ctx, string key, [RemainingText] string value) + [Command("set")] + [Description("Sets configuration variable")] + public async Task Set(CommandContext ctx, string key, [RemainingText] string value) + { + Config.InMemorySettings[key] = value; + Config.RebuildConfiguration(); + key = SqlConfiguration.ConfigVarPrefix + key; + await using var db = new BotDb(); + var stateValue = await db.BotState.Where(v => v.Key == key).FirstOrDefaultAsync().ConfigureAwait(false); + if (stateValue == null) { - Config.InMemorySettings[key] = value; - Config.RebuildConfiguration(); - key = SqlConfiguration.ConfigVarPrefix + key; - await using var db = new BotDb(); - var stateValue = await db.BotState.Where(v => v.Key == key).FirstOrDefaultAsync().ConfigureAwait(false); - if (stateValue == null) - { - stateValue = new BotState {Key = key, Value = value}; - await db.BotState.AddAsync(stateValue).ConfigureAwait(false); - } - else - stateValue.Value = value; + stateValue = new BotState {Key = key, Value = value}; + await db.BotState.AddAsync(stateValue).ConfigureAwait(false); + } + else + stateValue.Value = value; + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, "Set variable successfully").ConfigureAwait(false); + } + + [Command("clear"), Aliases("unset", "remove", "reset")] + [Description("Removes configuration variable")] + public async Task Clear(CommandContext ctx, string key) + { + Config.InMemorySettings.TryRemove(key, out _); + Config.RebuildConfiguration(); + key = SqlConfiguration.ConfigVarPrefix + key; + await using var db = new BotDb(); + var stateValue = await db.BotState.Where(v => v.Key == key).FirstOrDefaultAsync().ConfigureAwait(false); + if (stateValue != null) + { + db.BotState.Remove(stateValue); await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, "Set variable successfully").ConfigureAwait(false); - } - - [Command("clear"), Aliases("unset", "remove", "reset")] - [Description("Removes configuration variable")] - public async Task Clear(CommandContext ctx, string key) - { - Config.InMemorySettings.TryRemove(key, out _); - Config.RebuildConfiguration(); - key = SqlConfiguration.ConfigVarPrefix + key; - await using var db = new BotDb(); - var stateValue = await db.BotState.Where(v => v.Key == key).FirstOrDefaultAsync().ConfigureAwait(false); - if (stateValue != null) - { - db.BotState.Remove(stateValue); - await db.SaveChangesAsync().ConfigureAwait(false); - } - await ctx.ReactWithAsync(Config.Reactions.Success, "Removed variable successfully").ConfigureAwait(false); } + await ctx.ReactWithAsync(Config.Reactions.Success, "Removed variable successfully").ConfigureAwait(false); } } } diff --git a/CompatBot/Commands/Sudo.Bot.cs b/CompatBot/Commands/Sudo.Bot.cs index bd5a554d..4b3a3204 100644 --- a/CompatBot/Commands/Sudo.Bot.cs +++ b/CompatBot/Commands/Sudo.Bot.cs @@ -13,231 +13,230 @@ using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal partial class Sudo { - internal partial class Sudo + private static readonly SemaphoreSlim LockObj = new(1, 1); + private static readonly SemaphoreSlim ImportLockObj = new(1, 1); + private static readonly ProcessStartInfo RestartInfo = new("dotnet", $"run -c Release"); + + [Group("bot"), Aliases("kot")] + [Description("Commands to manage the bot instance")] + public sealed partial class Bot: BaseCommandModuleCustom { - private static readonly SemaphoreSlim LockObj = new(1, 1); - private static readonly SemaphoreSlim ImportLockObj = new(1, 1); - private static readonly ProcessStartInfo RestartInfo = new("dotnet", $"run -c Release"); - - [Group("bot"), Aliases("kot")] - [Description("Commands to manage the bot instance")] - public sealed partial class Bot: BaseCommandModuleCustom + [Command("version")] + [Description("Returns currently checked out bot commit")] + public async Task Version(CommandContext ctx) { - [Command("version")] - [Description("Returns currently checked out bot commit")] - public async Task Version(CommandContext ctx) + using var git = new Process { - using var git = new Process + StartInfo = new("git", "log -1 --oneline") { - StartInfo = new("git", "log -1 --oneline") - { - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardOutput = true, - StandardOutputEncoding = Encoding.UTF8, - }, - }; - git.Start(); - var stdout = await git.StandardOutput.ReadToEndAsync().ConfigureAwait(false); - await git.WaitForExitAsync().ConfigureAwait(false); - if (!string.IsNullOrEmpty(stdout)) - await ctx.Channel.SendMessageAsync("```" + stdout + "```").ConfigureAwait(false); - } + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }, + }; + git.Start(); + var stdout = await git.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + await git.WaitForExitAsync().ConfigureAwait(false); + if (!string.IsNullOrEmpty(stdout)) + await ctx.Channel.SendMessageAsync("```" + stdout + "```").ConfigureAwait(false); + } - [Command("update"), Aliases("upgrade", "pull", "pet")] - [Description("Updates the bot, and then restarts it")] - public async Task Update(CommandContext ctx) - { - if (await LockObj.WaitAsync(0).ConfigureAwait(false)) - { - DiscordMessage? msg = null; - try - { - Config.Log.Info("Checking for available bot updates..."); - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Checking for bot updates...").ConfigureAwait(false); - var (updated, stdout) = await UpdateAsync().ConfigureAwait(false); - if (!string.IsNullOrEmpty(stdout)) - await ctx.SendAutosplitMessageAsync("```" + stdout + "```").ConfigureAwait(false); - if (!updated) - return; - - msg = await ctx.Channel.SendMessageAsync("Saving state...").ConfigureAwait(false); - await StatsStorage.SaveAsync(true).ConfigureAwait(false); - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Restarting...").ConfigureAwait(false); - Restart(ctx.Channel.Id, "Restarted after successful bot update"); - } - catch (Exception e) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Updating failed: " + e.Message).ConfigureAwait(false); - } - finally - { - LockObj.Release(); - } - } - else - await ctx.Channel.SendMessageAsync("Update is already in progress").ConfigureAwait(false); - } - - [Command("restart"), Aliases("reboot")] - [Description("Restarts the bot")] - public async Task Restart(CommandContext ctx) - { - if (await LockObj.WaitAsync(0).ConfigureAwait(false)) - { - DiscordMessage? msg = null; - try - { - msg = await ctx.Channel.SendMessageAsync("Saving state...").ConfigureAwait(false); - await StatsStorage.SaveAsync(true).ConfigureAwait(false); - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Restarting...").ConfigureAwait(false); - Restart(ctx.Channel.Id, "Restarted due to command request"); - } - catch (Exception e) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Restarting failed: " + e.Message).ConfigureAwait(false); - } - finally - { - LockObj.Release(); - } - } - else - await ctx.Channel.SendMessageAsync("Update is in progress").ConfigureAwait(false); - } - - [Command("stop"), Aliases("exit", "shutdown", "terminate")] - [Description("Stops the bot. Useful if you can't find where you left one running")] - public async Task Stop(CommandContext ctx) - { - await ctx.Channel.SendMessageAsync(ctx.Channel.IsPrivate - ? $"Shutting down bot instance on {Environment.MachineName}..." - : "Shutting down the bot..." - ).ConfigureAwait(false); - Config.Log.Info($"Shutting down by request from {ctx.User.Username}#{ctx.User.Discriminator}"); - Config.InMemorySettings["shutdown"] = "true"; - Config.Cts.Cancel(); - } - - [Command("status")] - [Description("Sets bot status with specified activity and message")] - public async Task Status(CommandContext ctx, [Description("One of: None, Playing, Watching or ListeningTo")] string activity, [RemainingText] string message) + [Command("update"), Aliases("upgrade", "pull", "pet")] + [Description("Updates the bot, and then restarts it")] + public async Task Update(CommandContext ctx) + { + if (await LockObj.WaitAsync(0).ConfigureAwait(false)) { + DiscordMessage? msg = null; try { - await using var db = new BotDb(); - var status = await db.BotState.FirstOrDefaultAsync(s => s.Key == "bot-status-activity").ConfigureAwait(false); - var txt = await db.BotState.FirstOrDefaultAsync(s => s.Key == "bot-status-text").ConfigureAwait(false); - if (Enum.TryParse(activity, true, out ActivityType activityType) - && !string.IsNullOrEmpty(message)) - { - if (status == null) - await db.BotState.AddAsync(new() {Key = "bot-status-activity", Value = activity}).ConfigureAwait(false); - else - status.Value = activity; - if (txt == null) - await db.BotState.AddAsync(new() {Key = "bot-status-text", Value = message}).ConfigureAwait(false); - else - txt.Value = message; - await ctx.Client.UpdateStatusAsync(new(message, activityType), UserStatus.Online).ConfigureAwait(false); - } - else - { - if (status != null) - db.BotState.Remove(status); - await ctx.Client.UpdateStatusAsync(new()).ConfigureAwait(false); - } - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + Config.Log.Info("Checking for available bot updates..."); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Checking for bot updates...").ConfigureAwait(false); + var (updated, stdout) = await UpdateAsync().ConfigureAwait(false); + if (!string.IsNullOrEmpty(stdout)) + await ctx.SendAutosplitMessageAsync("```" + stdout + "```").ConfigureAwait(false); + if (!updated) + return; + + msg = await ctx.Channel.SendMessageAsync("Saving state...").ConfigureAwait(false); + await StatsStorage.SaveAsync(true).ConfigureAwait(false); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Restarting...").ConfigureAwait(false); + Restart(ctx.Channel.Id, "Restarted after successful bot update"); } catch (Exception e) { - Config.Log.Error(e); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Updating failed: " + e.Message).ConfigureAwait(false); + } + finally + { + LockObj.Release(); } } + else + await ctx.Channel.SendMessageAsync("Update is already in progress").ConfigureAwait(false); + } - [Command("import_metacritic"), Aliases("importmc", "imc"), TriggersTyping] - [Description("Imports Metacritic database dump and links it to existing items")] - public async Task ImportMc(CommandContext ctx) + [Command("restart"), Aliases("reboot")] + [Description("Restarts the bot")] + public async Task Restart(CommandContext ctx) + { + if (await LockObj.WaitAsync(0).ConfigureAwait(false)) { - if (await ImportLockObj.WaitAsync(0).ConfigureAwait(false)) - try - { - await CompatList.ImportMetacriticScoresAsync().ConfigureAwait(false); - await using var db = new ThumbnailDb(); - var linkedItems = await db.Thumbnail.CountAsync(i => i.MetacriticId != null).ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"Importing Metacritic info was successful, linked {linkedItems} items").ConfigureAwait(false); - } - finally - { - ImportLockObj.Release(); - } - else - await ctx.Channel.SendMessageAsync("Another import operation is already in progress").ConfigureAwait(false); - } - - internal static async Task<(bool updated, string stdout)> UpdateAsync() - { - using var git = new Process + DiscordMessage? msg = null; + try { - StartInfo = new("git", "pull") - { - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardOutput = true, - StandardOutputEncoding = Encoding.UTF8, - }, - }; - git.Start(); - var stdout = await git.StandardOutput.ReadToEndAsync().ConfigureAwait(false); - await git.WaitForExitAsync().ConfigureAwait(false); - if (string.IsNullOrEmpty(stdout)) - return (false, stdout); - - if (stdout.Contains("Already up to date", StringComparison.InvariantCultureIgnoreCase)) - return (false, stdout); - - return (true, stdout); - } - - internal static void Restart(ulong channelId, string? restartMsg) - { - Config.Log.Info($"Saving channelId {channelId} into settings..."); - using var db = new BotDb(); - var ch = db.BotState.FirstOrDefault(k => k.Key == "bot-restart-channel"); - if (ch is null) + msg = await ctx.Channel.SendMessageAsync("Saving state...").ConfigureAwait(false); + await StatsStorage.SaveAsync(true).ConfigureAwait(false); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Restarting...").ConfigureAwait(false); + Restart(ctx.Channel.Id, "Restarted due to command request"); + } + catch (Exception e) { - ch = new() {Key = "bot-restart-channel", Value = channelId.ToString()}; - db.BotState.Add(ch); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Restarting failed: " + e.Message).ConfigureAwait(false); + } + finally + { + LockObj.Release(); + } + } + else + await ctx.Channel.SendMessageAsync("Update is in progress").ConfigureAwait(false); + } + + [Command("stop"), Aliases("exit", "shutdown", "terminate")] + [Description("Stops the bot. Useful if you can't find where you left one running")] + public async Task Stop(CommandContext ctx) + { + await ctx.Channel.SendMessageAsync(ctx.Channel.IsPrivate + ? $"Shutting down bot instance on {Environment.MachineName}..." + : "Shutting down the bot..." + ).ConfigureAwait(false); + Config.Log.Info($"Shutting down by request from {ctx.User.Username}#{ctx.User.Discriminator}"); + Config.InMemorySettings["shutdown"] = "true"; + Config.Cts.Cancel(); + } + + [Command("status")] + [Description("Sets bot status with specified activity and message")] + public async Task Status(CommandContext ctx, [Description("One of: None, Playing, Watching or ListeningTo")] string activity, [RemainingText] string message) + { + try + { + await using var db = new BotDb(); + var status = await db.BotState.FirstOrDefaultAsync(s => s.Key == "bot-status-activity").ConfigureAwait(false); + var txt = await db.BotState.FirstOrDefaultAsync(s => s.Key == "bot-status-text").ConfigureAwait(false); + if (Enum.TryParse(activity, true, out ActivityType activityType) + && !string.IsNullOrEmpty(message)) + { + if (status == null) + await db.BotState.AddAsync(new() {Key = "bot-status-activity", Value = activity}).ConfigureAwait(false); + else + status.Value = activity; + if (txt == null) + await db.BotState.AddAsync(new() {Key = "bot-status-text", Value = message}).ConfigureAwait(false); + else + txt.Value = message; + await ctx.Client.UpdateStatusAsync(new(message, activityType), UserStatus.Online).ConfigureAwait(false); } else - ch.Value = channelId.ToString(); - var msg = db.BotState.FirstOrDefault(k => k.Key == "bot-restart-msg"); - if (msg is null) { - msg = new() {Key = "bot-restart-msg", Value = restartMsg}; - db.BotState.Add(msg); + if (status != null) + db.BotState.Remove(status); + await ctx.Client.UpdateStatusAsync(new()).ConfigureAwait(false); } - else - msg.Value = restartMsg; - db.SaveChanges(); - Config.TelemetryClient?.TrackEvent("Restart"); - RestartNoSaving(); + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); } - - internal static void RestartNoSaving() + catch (Exception e) { - if (SandboxDetector.Detect() != SandboxType.Docker) - { - Config.Log.Info("Restarting..."); - using var self = new Process {StartInfo = RestartInfo}; - self.Start(); - Config.InMemorySettings["shutdown"] = "true"; - Config.Cts.Cancel(); - } - Environment.Exit(-1); + Config.Log.Error(e); } } + + [Command("import_metacritic"), Aliases("importmc", "imc"), TriggersTyping] + [Description("Imports Metacritic database dump and links it to existing items")] + public async Task ImportMc(CommandContext ctx) + { + if (await ImportLockObj.WaitAsync(0).ConfigureAwait(false)) + try + { + await CompatList.ImportMetacriticScoresAsync().ConfigureAwait(false); + await using var db = new ThumbnailDb(); + var linkedItems = await db.Thumbnail.CountAsync(i => i.MetacriticId != null).ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"Importing Metacritic info was successful, linked {linkedItems} items").ConfigureAwait(false); + } + finally + { + ImportLockObj.Release(); + } + else + await ctx.Channel.SendMessageAsync("Another import operation is already in progress").ConfigureAwait(false); + } + + internal static async Task<(bool updated, string stdout)> UpdateAsync() + { + using var git = new Process + { + StartInfo = new("git", "pull") + { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }, + }; + git.Start(); + var stdout = await git.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + await git.WaitForExitAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(stdout)) + return (false, stdout); + + if (stdout.Contains("Already up to date", StringComparison.InvariantCultureIgnoreCase)) + return (false, stdout); + + return (true, stdout); + } + + internal static void Restart(ulong channelId, string? restartMsg) + { + Config.Log.Info($"Saving channelId {channelId} into settings..."); + using var db = new BotDb(); + var ch = db.BotState.FirstOrDefault(k => k.Key == "bot-restart-channel"); + if (ch is null) + { + ch = new() {Key = "bot-restart-channel", Value = channelId.ToString()}; + db.BotState.Add(ch); + } + else + ch.Value = channelId.ToString(); + var msg = db.BotState.FirstOrDefault(k => k.Key == "bot-restart-msg"); + if (msg is null) + { + msg = new() {Key = "bot-restart-msg", Value = restartMsg}; + db.BotState.Add(msg); + } + else + msg.Value = restartMsg; + db.SaveChanges(); + Config.TelemetryClient?.TrackEvent("Restart"); + RestartNoSaving(); + } + + internal static void RestartNoSaving() + { + if (SandboxDetector.Detect() != SandboxType.Docker) + { + Config.Log.Info("Restarting..."); + using var self = new Process {StartInfo = RestartInfo}; + self.Start(); + Config.InMemorySettings["shutdown"] = "true"; + Config.Cts.Cancel(); + } + Environment.Exit(-1); + } } -} +} \ No newline at end of file diff --git a/CompatBot/Commands/Sudo.Dotnet.cs b/CompatBot/Commands/Sudo.Dotnet.cs index f6e6c6f3..8e19e487 100644 --- a/CompatBot/Commands/Sudo.Dotnet.cs +++ b/CompatBot/Commands/Sudo.Dotnet.cs @@ -9,100 +9,99 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal partial class Sudo { - internal partial class Sudo + [Group("dotnet")] + [Description("Commands to manage dotnet")] + public sealed partial class Dotnet : BaseCommandModuleCustom { - [Group("dotnet")] - [Description("Commands to manage dotnet")] - public sealed partial class Dotnet : BaseCommandModuleCustom + [Command("update"), Aliases("upgrade")] + [Description("Updates dotnet, and then restarts the bot")] + public async Task Update(CommandContext ctx, [Description("Dotnet SDK version (e.g. `5.1`)")] string version = "") { - [Command("update"), Aliases("upgrade")] - [Description("Updates dotnet, and then restarts the bot")] - public async Task Update(CommandContext ctx, [Description("Dotnet SDK version (e.g. `5.1`)")] string version = "") + if (await LockObj.WaitAsync(0).ConfigureAwait(false)) { - if (await LockObj.WaitAsync(0).ConfigureAwait(false)) + DiscordMessage? msg = null; + try { - DiscordMessage? msg = null; - try - { - Config.Log.Info("Checking for available dotnet updates..."); - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Checking for dotnet updates...").ConfigureAwait(false); - var (updated, stdout) = await UpdateAsync(version).ConfigureAwait(false); - if (!string.IsNullOrEmpty(stdout)) - await ctx.SendAutosplitMessageAsync("```" + stdout + "```").ConfigureAwait(false); - if (!updated) - return; + Config.Log.Info("Checking for available dotnet updates..."); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Checking for dotnet updates...").ConfigureAwait(false); + var (updated, stdout) = await UpdateAsync(version).ConfigureAwait(false); + if (!string.IsNullOrEmpty(stdout)) + await ctx.SendAutosplitMessageAsync("```" + stdout + "```").ConfigureAwait(false); + if (!updated) + return; - msg = await ctx.Channel.SendMessageAsync("Saving state...").ConfigureAwait(false); - await StatsStorage.SaveAsync(true).ConfigureAwait(false); - msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Restarting...").ConfigureAwait(false); - Bot.Restart(ctx.Channel.Id, "Restarted after successful dotnet update"); - } - catch (Exception e) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Updating failed: " + e.Message).ConfigureAwait(false); - } - finally - { - LockObj.Release(); - } + msg = await ctx.Channel.SendMessageAsync("Saving state...").ConfigureAwait(false); + await StatsStorage.SaveAsync(true).ConfigureAwait(false); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Restarting...").ConfigureAwait(false); + Bot.Restart(ctx.Channel.Id, "Restarted after successful dotnet update"); } - else - await ctx.Channel.SendMessageAsync("Update is already in progress").ConfigureAwait(false); - } - - private static async Task<(bool updated, string stdout)> UpdateAsync(string version) - { - using var aptUpdate = new Process + catch (Exception e) { - StartInfo = new("apt-get", "update") - { - CreateNoWindow = true, - UseShellExecute = false, - }, - }; - aptUpdate.Start(); - await aptUpdate.WaitForExitAsync().ConfigureAwait(false); - - if (string.IsNullOrEmpty(version)) - { - var versionMatch = Regex.Match( - System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, - @"\.NET( Core)? (?\d+)\.(?\d+)\.(?\d+)(-.+)?", - RegexOptions.Singleline | RegexOptions.ExplicitCapture - ); - if (!versionMatch.Success) - throw new InvalidOperationException("Failed to resolve required dotnet sdk version"); - - version = $"{versionMatch.Groups["major"].Value}.{versionMatch.Groups["minor"].Value}"; + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Updating failed: " + e.Message).ConfigureAwait(false); } - using var aptUpgrade = new Process + finally { - StartInfo = new("apt-get", $"-y --allow-unauthenticated --only-upgrade install dotnet-sdk-{version}") - { - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardOutput = true, - StandardOutputEncoding = Encoding.UTF8, - }, - }; - aptUpgrade.Start(); - var stdout = await aptUpgrade.StandardOutput.ReadToEndAsync().ConfigureAwait(false); - await aptUpgrade.WaitForExitAsync().ConfigureAwait(false); - if (string.IsNullOrEmpty(stdout)) - return (false, stdout); - - if (!stdout.Contains("dotnet-sdk-")) - return (false, stdout); - - //var resultsMatch = Regex.Match(stdout, @"(?\d+) upgraded, (?\d+) newly installed"); - if (stdout.Contains("is already the newest version", StringComparison.InvariantCultureIgnoreCase)) - return (false, stdout); - - return (true, stdout); + LockObj.Release(); + } } - + else + await ctx.Channel.SendMessageAsync("Update is already in progress").ConfigureAwait(false); } + + private static async Task<(bool updated, string stdout)> UpdateAsync(string version) + { + using var aptUpdate = new Process + { + StartInfo = new("apt-get", "update") + { + CreateNoWindow = true, + UseShellExecute = false, + }, + }; + aptUpdate.Start(); + await aptUpdate.WaitForExitAsync().ConfigureAwait(false); + + if (string.IsNullOrEmpty(version)) + { + var versionMatch = Regex.Match( + System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, + @"\.NET( Core)? (?\d+)\.(?\d+)\.(?\d+)(-.+)?", + RegexOptions.Singleline | RegexOptions.ExplicitCapture + ); + if (!versionMatch.Success) + throw new InvalidOperationException("Failed to resolve required dotnet sdk version"); + + version = $"{versionMatch.Groups["major"].Value}.{versionMatch.Groups["minor"].Value}"; + } + using var aptUpgrade = new Process + { + StartInfo = new("apt-get", $"-y --allow-unauthenticated --only-upgrade install dotnet-sdk-{version}") + { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }, + }; + aptUpgrade.Start(); + var stdout = await aptUpgrade.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + await aptUpgrade.WaitForExitAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(stdout)) + return (false, stdout); + + if (!stdout.Contains("dotnet-sdk-")) + return (false, stdout); + + //var resultsMatch = Regex.Match(stdout, @"(?\d+) upgraded, (?\d+) newly installed"); + if (stdout.Contains("is already the newest version", StringComparison.InvariantCultureIgnoreCase)) + return (false, stdout); + + return (true, stdout); + } + } } \ No newline at end of file diff --git a/CompatBot/Commands/Sudo.Fix.cs b/CompatBot/Commands/Sudo.Fix.cs index c565d434..bd5daeb5 100644 --- a/CompatBot/Commands/Sudo.Fix.cs +++ b/CompatBot/Commands/Sudo.Fix.cs @@ -9,168 +9,167 @@ using CompatBot.Utils; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal sealed partial class Sudo { - internal sealed partial class Sudo + // '2018-06-09 08:20:44.968000 - ' + // '2018-07-19T12:19:06.7888609Z - ' + private static readonly Regex Timestamp = new(@"^(?(?\d{4}-\d\d-\d\d[ T][0-9:\.]+Z?) - )", RegexOptions.ExplicitCapture | RegexOptions.Singleline); + private static readonly Regex Channel = new(@"(?<#\d+>)", RegexOptions.ExplicitCapture | RegexOptions.Singleline); + + [Group("fix"), Hidden] + [Description("Commands to fix various stuff")] + public sealed class Fix: BaseCommandModuleCustom { - // '2018-06-09 08:20:44.968000 - ' - // '2018-07-19T12:19:06.7888609Z - ' - private static readonly Regex Timestamp = new(@"^(?(?\d{4}-\d\d-\d\d[ T][0-9:\.]+Z?) - )", RegexOptions.ExplicitCapture | RegexOptions.Singleline); - private static readonly Regex Channel = new(@"(?<#\d+>)", RegexOptions.ExplicitCapture | RegexOptions.Singleline); - - [Group("fix"), Hidden] - [Description("Commands to fix various stuff")] - public sealed class Fix: BaseCommandModuleCustom + [Command("timestamps")] + [Description("Fixes `timestamp` column in the `warning` table")] + public async Task Timestamps(CommandContext ctx) { - [Command("timestamps")] - [Description("Fixes `timestamp` column in the `warning` table")] - public async Task Timestamps(CommandContext ctx) + try { - try - { - var @fixed = 0; - await using var db = new BotDb(); - foreach (var warning in db.Warning) - if (!string.IsNullOrEmpty(warning.FullReason)) - { - var match = Timestamp.Match(warning.FullReason); - if (match.Success && DateTime.TryParse(match.Groups["date"].Value, out var timestamp)) - { - warning.Timestamp = timestamp.Ticks; - warning.FullReason = warning.FullReason[(match.Groups["cutout"].Value.Length)..]; - @fixed++; - } - } - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"Fixed {@fixed} records").ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Couldn't fix warning timestamps"); - await ctx.Channel.SendMessageAsync("Failed to fix warning timestamps").ConfigureAwait(false); - } - } - - [Command("channels")] - [Description("Fixes channel mentions in `warning` table")] - public async Task Channels(CommandContext ctx) - { - try - { - var @fixed = 0; - await using var db = new BotDb(); - foreach (var warning in db.Warning) + var @fixed = 0; + await using var db = new BotDb(); + foreach (var warning in db.Warning) + if (!string.IsNullOrEmpty(warning.FullReason)) { - var newReason = await FixChannelMentionAsync(ctx, warning.Reason).ConfigureAwait(false); - if (newReason != warning.Reason && newReason != null) + var match = Timestamp.Match(warning.FullReason); + if (match.Success && DateTime.TryParse(match.Groups["date"].Value, out var timestamp)) { - warning.Reason = newReason; + warning.Timestamp = timestamp.Ticks; + warning.FullReason = warning.FullReason[(match.Groups["cutout"].Value.Length)..]; @fixed++; } } - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"Fixed {@fixed} records").ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Couldn't fix channel mentions"); - await ctx.Channel.SendMessageAsync("Failed to fix warning timestamps").ConfigureAwait(false); - } + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"Fixed {@fixed} records").ConfigureAwait(false); } - - [Command("syscalls")] - [Description("Fixes invalid function names in `syscall-info` table and associated data")] - public async Task Syscalls(CommandContext ctx) + catch (Exception e) { - try - { - await ctx.Channel.SendMessageAsync("Fixing invalid function names...").ConfigureAwait(false); - var result = await SyscallInfoProvider.FixInvalidFunctionNamesAsync().ConfigureAwait(false); - if (result.funcs > 0) - await ctx.Channel.SendMessageAsync($"Successfully fixed {result.funcs} function name{(result.funcs == 1 ? "" : "s")} and {result.links} game link{(result.links == 1 ? "" : "s")}").ConfigureAwait(false); - else - await ctx.Channel.SendMessageAsync("No invalid syscall functions detected").ConfigureAwait(false); - - await ctx.Channel.SendMessageAsync("Fixing duplicates...").ConfigureAwait(false); - result = await SyscallInfoProvider.FixDuplicatesAsync().ConfigureAwait(false); - if (result.funcs > 0) - await ctx.Channel.SendMessageAsync($"Successfully merged {result.funcs} function{(result.funcs == 1 ? "" : "s")} and {result.links} game link{(result.links == 1 ? "" : "s")}").ConfigureAwait(false); - else - await ctx.Channel.SendMessageAsync("No duplicate function entries found").ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to fix syscall info"); - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to fix syscall information", true).ConfigureAwait(false); - } + Config.Log.Warn(e, "Couldn't fix warning timestamps"); + await ctx.Channel.SendMessageAsync("Failed to fix warning timestamps").ConfigureAwait(false); } + } - [Command("title_marks"), Aliases("trademarks", "tms")] - [Description("Strips trade marks and similar cruft from game titles in local database")] - public async Task TitleMarks(CommandContext ctx) + [Command("channels")] + [Description("Fixes channel mentions in `warning` table")] + public async Task Channels(CommandContext ctx) + { + try { - var changed = 0; - await using var db = new ThumbnailDb(); - foreach (var thumb in db.Thumbnail) + var @fixed = 0; + await using var db = new BotDb(); + foreach (var warning in db.Warning) { - if (string.IsNullOrEmpty(thumb.Name)) - continue; + var newReason = await FixChannelMentionAsync(ctx, warning.Reason).ConfigureAwait(false); + if (newReason != warning.Reason && newReason != null) + { + warning.Reason = newReason; + @fixed++; + } + } + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"Fixed {@fixed} records").ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Couldn't fix channel mentions"); + await ctx.Channel.SendMessageAsync("Failed to fix warning timestamps").ConfigureAwait(false); + } + } - var newTitle = thumb.Name.StripMarks(); - if (newTitle.EndsWith("full game", StringComparison.OrdinalIgnoreCase)) - newTitle = newTitle[..^10]; - if (newTitle.EndsWith("full game unlock", StringComparison.OrdinalIgnoreCase)) - newTitle = newTitle[..^17]; - if (newTitle.EndsWith("downloadable game", StringComparison.OrdinalIgnoreCase)) - newTitle = newTitle[..^18]; - newTitle = newTitle.TrimEnd(); - if (newTitle == thumb.Name) - continue; + [Command("syscalls")] + [Description("Fixes invalid function names in `syscall-info` table and associated data")] + public async Task Syscalls(CommandContext ctx) + { + try + { + await ctx.Channel.SendMessageAsync("Fixing invalid function names...").ConfigureAwait(false); + var result = await SyscallInfoProvider.FixInvalidFunctionNamesAsync().ConfigureAwait(false); + if (result.funcs > 0) + await ctx.Channel.SendMessageAsync($"Successfully fixed {result.funcs} function name{(result.funcs == 1 ? "" : "s")} and {result.links} game link{(result.links == 1 ? "" : "s")}").ConfigureAwait(false); + else + await ctx.Channel.SendMessageAsync("No invalid syscall functions detected").ConfigureAwait(false); + + await ctx.Channel.SendMessageAsync("Fixing duplicates...").ConfigureAwait(false); + result = await SyscallInfoProvider.FixDuplicatesAsync().ConfigureAwait(false); + if (result.funcs > 0) + await ctx.Channel.SendMessageAsync($"Successfully merged {result.funcs} function{(result.funcs == 1 ? "" : "s")} and {result.links} game link{(result.links == 1 ? "" : "s")}").ConfigureAwait(false); + else + await ctx.Channel.SendMessageAsync("No duplicate function entries found").ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to fix syscall info"); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to fix syscall information", true).ConfigureAwait(false); + } + } + + [Command("title_marks"), Aliases("trademarks", "tms")] + [Description("Strips trade marks and similar cruft from game titles in local database")] + public async Task TitleMarks(CommandContext ctx) + { + var changed = 0; + await using var db = new ThumbnailDb(); + foreach (var thumb in db.Thumbnail) + { + if (string.IsNullOrEmpty(thumb.Name)) + continue; + + var newTitle = thumb.Name.StripMarks(); + if (newTitle.EndsWith("full game", StringComparison.OrdinalIgnoreCase)) + newTitle = newTitle[..^10]; + if (newTitle.EndsWith("full game unlock", StringComparison.OrdinalIgnoreCase)) + newTitle = newTitle[..^17]; + if (newTitle.EndsWith("downloadable game", StringComparison.OrdinalIgnoreCase)) + newTitle = newTitle[..^18]; + newTitle = newTitle.TrimEnd(); + if (newTitle == thumb.Name) + continue; - changed++; - thumb.Name = newTitle; - } - await db.SaveChangesAsync(); - await ctx.Channel.SendMessageAsync($"Fixed {changed} title{(changed == 1 ? "" : "s")}").ConfigureAwait(false); + changed++; + thumb.Name = newTitle; } + await db.SaveChangesAsync(); + await ctx.Channel.SendMessageAsync($"Fixed {changed} title{(changed == 1 ? "" : "s")}").ConfigureAwait(false); + } - [Command("metacritic_links"), Aliases("mcl")] - [Description("Cleans up Metacritic links")] - public async Task MetacriticLinks(CommandContext ctx, [Description("Remove links for trial and demo versions only")] bool demosOnly = true) + [Command("metacritic_links"), Aliases("mcl")] + [Description("Cleans up Metacritic links")] + public async Task MetacriticLinks(CommandContext ctx, [Description("Remove links for trial and demo versions only")] bool demosOnly = true) + { + var changed = 0; + await using var db = new ThumbnailDb(); + foreach (var thumb in db.Thumbnail.Where(t => t.MetacriticId != null)) { - var changed = 0; - await using var db = new ThumbnailDb(); - foreach (var thumb in db.Thumbnail.Where(t => t.MetacriticId != null)) - { - if (demosOnly - && thumb.Name != null - && !Regex.IsMatch(thumb.Name, @"\b(demo|trial)\b", RegexOptions.IgnoreCase | RegexOptions.Singleline)) - continue; + if (demosOnly + && thumb.Name != null + && !Regex.IsMatch(thumb.Name, @"\b(demo|trial)\b", RegexOptions.IgnoreCase | RegexOptions.Singleline)) + continue; - thumb.MetacriticId = null; - changed++; - } - await db.SaveChangesAsync(); - await ctx.Channel.SendMessageAsync($"Fixed {changed} title{(changed == 1 ? "" : "s")}").ConfigureAwait(false); + thumb.MetacriticId = null; + changed++; } + await db.SaveChangesAsync(); + await ctx.Channel.SendMessageAsync($"Fixed {changed} title{(changed == 1 ? "" : "s")}").ConfigureAwait(false); + } - public static async Task FixChannelMentionAsync(CommandContext ctx, string? msg) - { - if (string.IsNullOrEmpty(msg)) - return msg; - - var entries = Channel.Matches(msg).Select(m => m.Groups["id"].Value).Distinct().ToList(); - if (entries.Count == 0) - return msg; - - foreach (var channel in entries) - { - var ch = await TextOnlyDiscordChannelConverter.ConvertAsync(channel, ctx).ConfigureAwait(false); - if (ch.HasValue) - msg = msg.Replace(channel, "#" + ch.Value.Name); - } + public static async Task FixChannelMentionAsync(CommandContext ctx, string? msg) + { + if (string.IsNullOrEmpty(msg)) return msg; + + var entries = Channel.Matches(msg).Select(m => m.Groups["id"].Value).Distinct().ToList(); + if (entries.Count == 0) + return msg; + + foreach (var channel in entries) + { + var ch = await TextOnlyDiscordChannelConverter.ConvertAsync(channel, ctx).ConfigureAwait(false); + if (ch.HasValue) + msg = msg.Replace(channel, "#" + ch.Value.Name); } + return msg; } } } \ No newline at end of file diff --git a/CompatBot/Commands/Sudo.Mod.cs b/CompatBot/Commands/Sudo.Mod.cs index 5d2e3a2c..9832b3b5 100644 --- a/CompatBot/Commands/Sudo.Mod.cs +++ b/CompatBot/Commands/Sudo.Mod.cs @@ -6,93 +6,92 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal partial class Sudo { - internal partial class Sudo + [Group("mod")] + [Description("Used to manage bot moderators")] + public sealed class Mod : BaseCommandModuleCustom { - [Group("mod")] - [Description("Used to manage bot moderators")] - public sealed class Mod : BaseCommandModuleCustom + [Command("add")] + [Description("Adds a new moderator")] + public async Task Add(CommandContext ctx, [Description("Discord user to add to the bot mod list")] DiscordMember user) { - [Command("add")] - [Description("Adds a new moderator")] - public async Task Add(CommandContext ctx, [Description("Discord user to add to the bot mod list")] DiscordMember user) + if (await ModProvider.AddAsync(user.Id).ConfigureAwait(false)) { - if (await ModProvider.AddAsync(user.Id).ConfigureAwait(false)) - { - await ctx.ReactWithAsync(Config.Reactions.Success, - $"{user.Mention} was successfully added as moderator!\n" + - $"Try using `{ctx.Prefix}help` to see new commands available to you" - ).ConfigureAwait(false); - } - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"{user.Mention} is already a moderator").ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, + $"{user.Mention} was successfully added as moderator!\n" + + $"Try using `{ctx.Prefix}help` to see new commands available to you" + ).ConfigureAwait(false); } + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"{user.Mention} is already a moderator").ConfigureAwait(false); + } - [Command("remove"), Aliases("delete", "del")] - [Description("Removes a moderator")] - public async Task Remove(CommandContext ctx, [Description("Discord user to remove from the bot mod list")] DiscordMember user) + [Command("remove"), Aliases("delete", "del")] + [Description("Removes a moderator")] + public async Task Remove(CommandContext ctx, [Description("Discord user to remove from the bot mod list")] DiscordMember user) + { + if (ctx.Client.CurrentApplication.Owners.Any(u => u.Id == user.Id)) { - if (ctx.Client.CurrentApplication.Owners.Any(u => u.Id == user.Id)) - { - var dm = await user.CreateDmChannelAsync().ConfigureAwait(false); - await dm.SendMessageAsync($@"Just letting you know that {ctx.Message.Author.Mention} just tried to strip you off of your mod role ¯\\_(ツ)_/¯").ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} why would you even try this?! Alerting {user.Mention}", true).ConfigureAwait(false); - } - else if (await ModProvider.RemoveAsync(user.Id).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Success, $"{user.Mention} removed as moderator!").ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"{user.Mention} is not a moderator").ConfigureAwait(false); + var dm = await user.CreateDmChannelAsync().ConfigureAwait(false); + await dm.SendMessageAsync($@"Just letting you know that {ctx.Message.Author.Mention} just tried to strip you off of your mod role ¯\\_(ツ)_/¯").ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} why would you even try this?! Alerting {user.Mention}", true).ConfigureAwait(false); } + else if (await ModProvider.RemoveAsync(user.Id).ConfigureAwait(false)) + await ctx.ReactWithAsync(Config.Reactions.Success, $"{user.Mention} removed as moderator!").ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"{user.Mention} is not a moderator").ConfigureAwait(false); + } - [Command("list"), Aliases("show")] - [Description("Lists all moderators")] - public async Task List(CommandContext ctx) - { - var table = new AsciiTable( - new AsciiColumn( "Username", maxWidth: 32), - new AsciiColumn("Sudo") - ); - foreach (var mod in ModProvider.Mods.Values.OrderByDescending(m => m.Sudoer)) - table.Add(await ctx.GetUserNameAsync(mod.DiscordId), mod.Sudoer ? "✅" :""); - await ctx.SendAutosplitMessageAsync(table.ToString()).ConfigureAwait(false); - } + [Command("list"), Aliases("show")] + [Description("Lists all moderators")] + public async Task List(CommandContext ctx) + { + var table = new AsciiTable( + new AsciiColumn( "Username", maxWidth: 32), + new AsciiColumn("Sudo") + ); + foreach (var mod in ModProvider.Mods.Values.OrderByDescending(m => m.Sudoer)) + table.Add(await ctx.GetUserNameAsync(mod.DiscordId), mod.Sudoer ? "✅" :""); + await ctx.SendAutosplitMessageAsync(table.ToString()).ConfigureAwait(false); + } - [Command("sudo")] - [Description("Makes a moderator a sudoer")] - public async Task Sudo(CommandContext ctx, [Description("Discord user on the moderator list to grant the sudoer rights to")] DiscordMember moderator) + [Command("sudo")] + [Description("Makes a moderator a sudoer")] + public async Task Sudo(CommandContext ctx, [Description("Discord user on the moderator list to grant the sudoer rights to")] DiscordMember moderator) + { + if (ModProvider.IsMod(moderator.Id)) { - if (ModProvider.IsMod(moderator.Id)) - { - if (await ModProvider.MakeSudoerAsync(moderator.Id).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Success, $"{moderator.Mention} is now a sudoer").ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"{moderator.Mention} is already a sudoer").ConfigureAwait(false); - } + if (await ModProvider.MakeSudoerAsync(moderator.Id).ConfigureAwait(false)) + await ctx.ReactWithAsync(Config.Reactions.Success, $"{moderator.Mention} is now a sudoer").ConfigureAwait(false); else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"{moderator.Mention} is not a moderator (yet)").ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Failure, $"{moderator.Mention} is already a sudoer").ConfigureAwait(false); } + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"{moderator.Mention} is not a moderator (yet)").ConfigureAwait(false); + } - [Command("unsudo")] - [Description("Makes a sudoer a regular moderator")] - public async Task Unsudo(CommandContext ctx, [Description("Discord user on the moderator list to strip the sudoer rights from")] DiscordMember sudoer) + [Command("unsudo")] + [Description("Makes a sudoer a regular moderator")] + public async Task Unsudo(CommandContext ctx, [Description("Discord user on the moderator list to strip the sudoer rights from")] DiscordMember sudoer) + { + if (ctx.Client.CurrentApplication.Owners.Any(u => u.Id == sudoer.Id)) { - if (ctx.Client.CurrentApplication.Owners.Any(u => u.Id == sudoer.Id)) - { - var dm = await sudoer.CreateDmChannelAsync().ConfigureAwait(false); - await dm.SendMessageAsync($@"Just letting you know that {ctx.Message.Author.Mention} just tried to strip you off of your sudo permissions ¯\\_(ツ)_/¯").ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} why would you even try this?! Alerting {sudoer.Mention}", true).ConfigureAwait(false); - } - else if (ModProvider.IsMod(sudoer.Id)) - { - if (await ModProvider.UnmakeSudoerAsync(sudoer.Id).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Success, $"{sudoer.Mention} is no longer a sudoer").ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"{sudoer.Mention} is not a sudoer").ConfigureAwait(false); - } - else - await ctx.ReactWithAsync(Config.Reactions.Failure, $"{sudoer.Mention} is not even a moderator!").ConfigureAwait(false); + var dm = await sudoer.CreateDmChannelAsync().ConfigureAwait(false); + await dm.SendMessageAsync($@"Just letting you know that {ctx.Message.Author.Mention} just tried to strip you off of your sudo permissions ¯\\_(ツ)_/¯").ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} why would you even try this?! Alerting {sudoer.Mention}", true).ConfigureAwait(false); } + else if (ModProvider.IsMod(sudoer.Id)) + { + if (await ModProvider.UnmakeSudoerAsync(sudoer.Id).ConfigureAwait(false)) + await ctx.ReactWithAsync(Config.Reactions.Success, $"{sudoer.Mention} is no longer a sudoer").ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"{sudoer.Mention} is not a sudoer").ConfigureAwait(false); + } + else + await ctx.ReactWithAsync(Config.Reactions.Failure, $"{sudoer.Mention} is not even a moderator!").ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/Commands/Sudo.cs b/CompatBot/Commands/Sudo.cs index 68feea78..3284365c 100644 --- a/CompatBot/Commands/Sudo.cs +++ b/CompatBot/Commands/Sudo.cs @@ -19,193 +19,192 @@ using SharpCompress.Compressors.Deflate; using SharpCompress.Writers; using SharpCompress.Writers.Zip; -namespace CompatBot.Commands -{ - [Group("sudo"), RequiresBotSudoerRole] - [Description("Used to manage bot moderators and sudoers")] - internal sealed partial class Sudo : BaseCommandModuleCustom - { - [Command("say")] - [Description("Make bot say things. Specify #channel or put message link in the beginning to specify where to reply")] - public async Task Say(CommandContext ctx, [RemainingText, Description("Message text to send")] string message) - { - var msgParts = message.Split(' ', 2, StringSplitOptions.TrimEntries); +namespace CompatBot.Commands; - var channel = ctx.Channel; - DiscordMessage? ogMsg = null; - if (msgParts.Length > 1) +[Group("sudo"), RequiresBotSudoerRole] +[Description("Used to manage bot moderators and sudoers")] +internal sealed partial class Sudo : BaseCommandModuleCustom +{ + [Command("say")] + [Description("Make bot say things. Specify #channel or put message link in the beginning to specify where to reply")] + public async Task Say(CommandContext ctx, [RemainingText, Description("Message text to send")] string message) + { + var msgParts = message.Split(' ', 2, StringSplitOptions.TrimEntries); + + var channel = ctx.Channel; + DiscordMessage? ogMsg = null; + if (msgParts.Length > 1) + { + if (await ctx.GetMessageAsync(msgParts[0]).ConfigureAwait(false) is DiscordMessage lnk) { - if (await ctx.GetMessageAsync(msgParts[0]).ConfigureAwait(false) is DiscordMessage lnk) - { - ogMsg = lnk; - channel = ogMsg.Channel; - message = msgParts[1]; - } - else if (await TextOnlyDiscordChannelConverter.ConvertAsync(msgParts[0], ctx).ConfigureAwait(false) is {HasValue: true} ch) - { - channel = ch.Value; - message = msgParts[1]; - } + ogMsg = lnk; + channel = ogMsg.Channel; + message = msgParts[1]; + } + else if (await TextOnlyDiscordChannelConverter.ConvertAsync(msgParts[0], ctx).ConfigureAwait(false) is {HasValue: true} ch) + { + channel = ch.Value; + message = msgParts[1]; + } + } + + var typingTask = channel.TriggerTypingAsync(); + // simulate bot typing the message at 300 cps + await Task.Delay(message.Length * 10 / 3).ConfigureAwait(false); + var msgBuilder = new DiscordMessageBuilder().WithContent(message); + if (ogMsg is not null) + msgBuilder.WithReply(ogMsg.Id); + if (ctx.Message.Attachments.Any()) + { + try + { + await using var memStream = Config.MemoryStreamManager.GetStream(); + using var client = HttpClientFactory.Create(new CompressionMessageHandler()); + await using var requestStream = await client.GetStreamAsync(ctx.Message.Attachments[0].Url!).ConfigureAwait(false); + await requestStream.CopyToAsync(memStream).ConfigureAwait(false); + memStream.Seek(0, SeekOrigin.Begin); + msgBuilder.WithFile(ctx.Message.Attachments[0].FileName, memStream); + await channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + } + catch { } + } + else + await channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + await typingTask.ConfigureAwait(false); + } + + [Command("react")] + [Description("Add reactions to the specified message")] + public async Task React( + CommandContext ctx, + [Description("Message link")] string messageLink, + [RemainingText, Description("List of reactions to add")]string emojis + ) + { + try + { + var message = await ctx.GetMessageAsync(messageLink).ConfigureAwait(false); + if (message is null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't find the message").ConfigureAwait(false); + return; } - var typingTask = channel.TriggerTypingAsync(); - // simulate bot typing the message at 300 cps - await Task.Delay(message.Length * 10 / 3).ConfigureAwait(false); - var msgBuilder = new DiscordMessageBuilder().WithContent(message); - if (ogMsg is not null) - msgBuilder.WithReply(ogMsg.Id); - if (ctx.Message.Attachments.Any()) + string emoji = ""; + for (var i = 0; i < emojis.Length; i++) { try { - await using var memStream = Config.MemoryStreamManager.GetStream(); - using var client = HttpClientFactory.Create(new CompressionMessageHandler()); - await using var requestStream = await client.GetStreamAsync(ctx.Message.Attachments[0].Url!).ConfigureAwait(false); - await requestStream.CopyToAsync(memStream).ConfigureAwait(false); - memStream.Seek(0, SeekOrigin.Begin); - msgBuilder.WithFile(ctx.Message.Attachments[0].FileName, memStream); - await channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + var c = emojis[i]; + if (char.IsHighSurrogate(c)) + emoji += c; + else + { + DiscordEmoji de; + if (c == '<') + { + var endIdx = emojis.IndexOf('>', i); + if (endIdx < i) + endIdx = emojis.Length; + emoji = emojis[i..endIdx]; + i = endIdx - 1; + var emojiId = ulong.Parse(emoji[(emoji.LastIndexOf(':') + 1)..]); + de = DiscordEmoji.FromGuildEmote(ctx.Client, emojiId); + } + else + de = DiscordEmoji.FromUnicode(emoji + c); + emoji = ""; + await message.ReactWithAsync(de).ConfigureAwait(false); + } } catch { } } - else - await channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); - await typingTask.ConfigureAwait(false); } - - [Command("react")] - [Description("Add reactions to the specified message")] - public async Task React( - CommandContext ctx, - [Description("Message link")] string messageLink, - [RemainingText, Description("List of reactions to add")]string emojis - ) + catch (Exception e) { - try - { - var message = await ctx.GetMessageAsync(messageLink).ConfigureAwait(false); - if (message is null) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't find the message").ConfigureAwait(false); - return; - } - - string emoji = ""; - for (var i = 0; i < emojis.Length; i++) - { - try - { - var c = emojis[i]; - if (char.IsHighSurrogate(c)) - emoji += c; - else - { - DiscordEmoji de; - if (c == '<') - { - var endIdx = emojis.IndexOf('>', i); - if (endIdx < i) - endIdx = emojis.Length; - emoji = emojis[i..endIdx]; - i = endIdx - 1; - var emojiId = ulong.Parse(emoji[(emoji.LastIndexOf(':') + 1)..]); - de = DiscordEmoji.FromGuildEmote(ctx.Client, emojiId); - } - else - de = DiscordEmoji.FromUnicode(emoji + c); - emoji = ""; - await message.ReactWithAsync(de).ConfigureAwait(false); - } - } - catch { } - } - } - catch (Exception e) - { - Config.Log.Debug(e); - } - } - - [Command("log"), RequiresDm] - [Description("Uploads current log file as an attachment")] - public async Task Log(CommandContext ctx, [Description("Specific date")]string date = "") - { - try - { - var logPath = Config.CurrentLogPath; - if (DateTime.TryParse(date, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var logDate)) - logPath = Path.Combine(Config.LogPath, $"bot.{logDate:yyyyMMdd}.0.log"); - if (!File.Exists(logPath)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "Log file does not exist for specified day", true).ConfigureAwait(false); - return; - } - - await using var log = File.Open(logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - await using var result = Config.MemoryStreamManager.GetStream(); - await using var gzip = new GZipStream(result, CompressionMode.Compress, CompressionLevel.Default); - await log.CopyToAsync(gzip, Config.Cts.Token).ConfigureAwait(false); - await gzip.FlushAsync().ConfigureAwait(false); - if (result.Length <= ctx.GetAttachmentSizeLimit()) - { - result.Seek(0, SeekOrigin.Begin); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile(Path.GetFileName(logPath) + ".gz", result)).ConfigureAwait(false); - } - else - await ctx.ReactWithAsync(Config.Reactions.Failure, "Compressed log size is too large, ask 13xforever for help :(", true).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to upload current log"); - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to send the log", true).ConfigureAwait(false); - } - } - - [Command("dbbackup"), Aliases("dbb")] - [Description("Uploads current Thumbs.db and Hardware.db files as an attachments")] - public async Task ThumbsBackup(CommandContext ctx) - { - try - { - string dbPath; - await using (var db = new ThumbnailDb()) - await using (var connection = db.Database.GetDbConnection()) - { - dbPath = connection.DataSource; - await db.Database.ExecuteSqlRawAsync("VACUUM;").ConfigureAwait(false); - } - var dbDir = Path.GetDirectoryName(dbPath) ?? "."; - var dbName = Path.GetFileNameWithoutExtension(dbPath); - await using var result = Config.MemoryStreamManager.GetStream(); - using var zip = new ZipWriter(result, new(CompressionType.LZMA){DeflateCompressionLevel = CompressionLevel.BestCompression}); - foreach (var fname in Directory.EnumerateFiles(dbDir, $"{dbName}.*", new EnumerationOptions {IgnoreInaccessible = true, RecurseSubdirectories = false,})) - { - await using var dbData = File.Open(fname, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - zip.Write(Path.GetFileName(fname), dbData); - } - if (result.Length <= ctx.GetAttachmentSizeLimit()) - { - result.Seek(0, SeekOrigin.Begin); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile(Path.GetFileName(dbName) + ".zip", result)).ConfigureAwait(false); - } - else - await ctx.ReactWithAsync(Config.Reactions.Failure, "Compressed Thumbs.db size is too large, ask 13xforever for help :(", true).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to upload current Thumbs.db backup"); - await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to send Thumbs.db backup", true).ConfigureAwait(false); - } - } - - [Command("gen-salt")] - [Description("Regenerates salt for data anonymization purposes. This WILL affect Hardware DB deduplication.")] - public Task ResetCryptoSalt(CommandContext ctx) - { - //todo: warning prompt - var salt = new byte[256 / 8]; - System.Security.Cryptography.RandomNumberGenerator.Fill(salt); - return new Bot.Configuration().Set(ctx, nameof(Config.CryptoSalt), Convert.ToBase64String(salt)); + Config.Log.Debug(e); } } -} + + [Command("log"), RequiresDm] + [Description("Uploads current log file as an attachment")] + public async Task Log(CommandContext ctx, [Description("Specific date")]string date = "") + { + try + { + var logPath = Config.CurrentLogPath; + if (DateTime.TryParse(date, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var logDate)) + logPath = Path.Combine(Config.LogPath, $"bot.{logDate:yyyyMMdd}.0.log"); + if (!File.Exists(logPath)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Log file does not exist for specified day", true).ConfigureAwait(false); + return; + } + + await using var log = File.Open(logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await using var result = Config.MemoryStreamManager.GetStream(); + await using var gzip = new GZipStream(result, CompressionMode.Compress, CompressionLevel.Default); + await log.CopyToAsync(gzip, Config.Cts.Token).ConfigureAwait(false); + await gzip.FlushAsync().ConfigureAwait(false); + if (result.Length <= ctx.GetAttachmentSizeLimit()) + { + result.Seek(0, SeekOrigin.Begin); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile(Path.GetFileName(logPath) + ".gz", result)).ConfigureAwait(false); + } + else + await ctx.ReactWithAsync(Config.Reactions.Failure, "Compressed log size is too large, ask 13xforever for help :(", true).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to upload current log"); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to send the log", true).ConfigureAwait(false); + } + } + + [Command("dbbackup"), Aliases("dbb")] + [Description("Uploads current Thumbs.db and Hardware.db files as an attachments")] + public async Task ThumbsBackup(CommandContext ctx) + { + try + { + string dbPath; + await using (var db = new ThumbnailDb()) + await using (var connection = db.Database.GetDbConnection()) + { + dbPath = connection.DataSource; + await db.Database.ExecuteSqlRawAsync("VACUUM;").ConfigureAwait(false); + } + var dbDir = Path.GetDirectoryName(dbPath) ?? "."; + var dbName = Path.GetFileNameWithoutExtension(dbPath); + await using var result = Config.MemoryStreamManager.GetStream(); + using var zip = new ZipWriter(result, new(CompressionType.LZMA){DeflateCompressionLevel = CompressionLevel.BestCompression}); + foreach (var fname in Directory.EnumerateFiles(dbDir, $"{dbName}.*", new EnumerationOptions {IgnoreInaccessible = true, RecurseSubdirectories = false,})) + { + await using var dbData = File.Open(fname, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + zip.Write(Path.GetFileName(fname), dbData); + } + if (result.Length <= ctx.GetAttachmentSizeLimit()) + { + result.Seek(0, SeekOrigin.Begin); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile(Path.GetFileName(dbName) + ".zip", result)).ConfigureAwait(false); + } + else + await ctx.ReactWithAsync(Config.Reactions.Failure, "Compressed Thumbs.db size is too large, ask 13xforever for help :(", true).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to upload current Thumbs.db backup"); + await ctx.ReactWithAsync(Config.Reactions.Failure, "Failed to send Thumbs.db backup", true).ConfigureAwait(false); + } + } + + [Command("gen-salt")] + [Description("Regenerates salt for data anonymization purposes. This WILL affect Hardware DB deduplication.")] + public Task ResetCryptoSalt(CommandContext ctx) + { + //todo: warning prompt + var salt = new byte[256 / 8]; + System.Security.Cryptography.RandomNumberGenerator.Fill(salt); + return new Bot.Configuration().Set(ctx, nameof(Config.CryptoSalt), Convert.ToBase64String(salt)); + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Syscall.cs b/CompatBot/Commands/Syscall.cs index 4ce103fd..a119def5 100644 --- a/CompatBot/Commands/Syscall.cs +++ b/CompatBot/Commands/Syscall.cs @@ -13,166 +13,165 @@ using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("syscall"), Aliases("syscalls", "cell", "sce", "scecall", "scecalls"), LimitedToSpamChannel] +[Description("Provides information about syscalls used by games")] +internal sealed class Syscall: BaseCommandModuleCustom { - [Group("syscall"), Aliases("syscalls", "cell", "sce", "scecall", "scecalls"), LimitedToSpamChannel] - [Description("Provides information about syscalls used by games")] - internal sealed class Syscall: BaseCommandModuleCustom + [GroupCommand] + public async Task Search(CommandContext ctx, [RemainingText, Description("Product ID, module, or function name. **Case sensitive**")] string search) { - [GroupCommand] - public async Task Search(CommandContext ctx, [RemainingText, Description("Product ID, module, or function name. **Case sensitive**")] string search) + if (string.IsNullOrEmpty(search)) { - if (string.IsNullOrEmpty(search)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "No meaningful search query provided").ConfigureAwait(false); - return; - } - - var productCodes = ProductCodeLookup.GetProductIds(search); - if (productCodes.Any()) - { - await ReturnSyscallsByGameAsync(ctx, productCodes.First()).ConfigureAwait(false); - return; - } - - if (ctx.User.Id == 216724245957312512UL && !search.StartsWith("sys_", StringComparison.InvariantCultureIgnoreCase)) - { - await ctx.Channel.SendMessageAsync($"This is not a _syscall_, {ctx.User.Mention}").ConfigureAwait(false); - return; - } - - await using var db = new ThumbnailDb(); - if (db.SyscallInfo.Any(sci => sci.Function == search)) - { - var productInfoList = db.SyscallToProductMap.AsNoTracking() - .Where(m => m.SyscallInfo.Function == search) - .Include(m => m.Product) - .AsEnumerable() - .Select(m => new {m.Product.ProductCode, Name = m.Product.Name?.StripMarks() ?? "???"}) - .Distinct() - .ToList(); - var groupedList = productInfoList - .GroupBy(m => m.Name, m => m.ProductCode, StringComparer.InvariantCultureIgnoreCase) - .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) - .ToList(); - if (groupedList.Any()) - { - var bigList = groupedList.Count >= Config.MaxSyscallResultLines; - var result = new StringBuilder(); - var fullList = bigList ? new StringBuilder() : null; - result.AppendLine($"List of games using `{search}`:```"); - var c = 0; - foreach (var gi in groupedList) - { - var productIds = string.Join(", ", gi.Distinct().OrderBy(pc => pc).AsEnumerable()); - if (c < Config.MaxSyscallResultLines) - result.AppendLine($"{gi.Key.Trim(60)} [{productIds}]"); - if (bigList) - fullList!.AppendLine($"{gi.Key} [{productIds}]"); - c++; - } - await ctx.SendAutosplitMessageAsync(result.Append("```")).ConfigureAwait(false); - if (bigList) - { - await using var memoryStream = Config.MemoryStreamManager.GetStream(); - await using var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8); - await streamWriter.WriteAsync(fullList).ConfigureAwait(false); - await streamWriter.FlushAsync().ConfigureAwait(false); - memoryStream.Seek(0, SeekOrigin.Begin); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile($"{search}.txt", memoryStream).WithContent($"See attached file for full list of {groupedList.Count} entries")).ConfigureAwait(false); - } - } - else - await ctx.Channel.SendMessageAsync($"No games found that use `{search}`").ConfigureAwait(false); - } - else - { - var result = new StringBuilder("Unknown entity name"); - var functions = await db.SyscallInfo.Select(sci => sci.Function).Distinct().ToListAsync().ConfigureAwait(false); - var substrFuncs = functions.Where(f => f.Contains(search, StringComparison.InvariantCultureIgnoreCase)); - var fuzzyFuncs = functions - .Select(f => (name: f, score: search.GetFuzzyCoefficientCached(f))) - .Where(i => i.score > 0.6) - .OrderByDescending(i => i.score) - .Select(i => i.name) - .ToList(); - functions = substrFuncs - .Concat(fuzzyFuncs) - .Distinct() - .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) - .ToList(); - var functionsFound = functions.Any(); - if (functionsFound) - { - result.AppendLine(", possible functions:```"); - foreach (var f in functions) - result.AppendLine(f); - result.AppendLine("```"); - } - await ctx.SendAutosplitMessageAsync(result).ConfigureAwait(false); - } + await ctx.ReactWithAsync(Config.Reactions.Failure, "No meaningful search query provided").ConfigureAwait(false); + return; } - [Command("rename"), RequiresBotModRole] - [Description("Provides an option to rename function call")] - public async Task Rename(CommandContext ctx, [Description("Old function name")] string oldFunctionName, [Description("New function name")] string newFunctionName) + var productCodes = ProductCodeLookup.GetProductIds(search); + if (productCodes.Any()) { - await using var db = new ThumbnailDb(); - var oldMatches = await db.SyscallInfo.Where(sci => sci.Function == oldFunctionName).ToListAsync().ConfigureAwait(false); - if (oldMatches.Count == 0) - { - await ctx.Channel.SendMessageAsync($"Function `{oldFunctionName}` could not be found").ConfigureAwait(false); - await Search(ctx, oldFunctionName).ConfigureAwait(false); - return; - } - - if (oldMatches.Count > 1) - { - await ctx.Channel.SendMessageAsync("More than one matching function was found, I can't handle this right now 😔").ConfigureAwait(false); - await Search(ctx, oldFunctionName).ConfigureAwait(false); - return; - } - - var conflicts = await db.SyscallInfo.Where(sce => sce.Function == newFunctionName).AnyAsync().ConfigureAwait(false); - if (conflicts) - { - await ctx.Channel.SendMessageAsync($"There is already a function `{newFunctionName}`").ConfigureAwait(false); - await Search(ctx, newFunctionName).ConfigureAwait(false); - return; - } - - oldMatches[0].Function = newFunctionName; - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"Function `{oldFunctionName}` was successfully renamed to `{newFunctionName}`").ConfigureAwait(false); + await ReturnSyscallsByGameAsync(ctx, productCodes.First()).ConfigureAwait(false); + return; } - private static async Task ReturnSyscallsByGameAsync(CommandContext ctx, string productId) + if (ctx.User.Id == 216724245957312512UL && !search.StartsWith("sys_", StringComparison.InvariantCultureIgnoreCase)) { - productId = productId.ToUpperInvariant(); - await using var db = new ThumbnailDb(); - var title = db.Thumbnail.FirstOrDefault(t => t.ProductCode == productId)?.Name; - title = string.IsNullOrEmpty(title) ? productId : $"[{productId}] {title.Trim(40)}"; - var sysInfoList = db.SyscallToProductMap.AsNoTracking() - .Where(m => m.Product.ProductCode == productId) - .Select(m => m.SyscallInfo) - .Distinct() + await ctx.Channel.SendMessageAsync($"This is not a _syscall_, {ctx.User.Mention}").ConfigureAwait(false); + return; + } + + await using var db = new ThumbnailDb(); + if (db.SyscallInfo.Any(sci => sci.Function == search)) + { + var productInfoList = db.SyscallToProductMap.AsNoTracking() + .Where(m => m.SyscallInfo.Function == search) + .Include(m => m.Product) .AsEnumerable() - .OrderBy(sci => sci.Function.TrimStart('_')) + .Select(m => new {m.Product.ProductCode, Name = m.Product.Name?.StripMarks() ?? "???"}) + .Distinct() .ToList(); - if (sysInfoList.Any()) + var groupedList = productInfoList + .GroupBy(m => m.Name, m => m.ProductCode, StringComparer.InvariantCultureIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (groupedList.Any()) { + var bigList = groupedList.Count >= Config.MaxSyscallResultLines; var result = new StringBuilder(); - foreach (var sci in sysInfoList) - result.AppendLine(sci.Function); - await using var memoryStream = Config.MemoryStreamManager.GetStream(); - await using var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8); - await streamWriter.WriteAsync(result).ConfigureAwait(false); - await streamWriter.FlushAsync().ConfigureAwait(false); - memoryStream.Seek(0, SeekOrigin.Begin); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile($"{productId} syscalls.txt", memoryStream).WithContent($"List of syscalls used by `{title}`")).ConfigureAwait(false); + var fullList = bigList ? new StringBuilder() : null; + result.AppendLine($"List of games using `{search}`:```"); + var c = 0; + foreach (var gi in groupedList) + { + var productIds = string.Join(", ", gi.Distinct().OrderBy(pc => pc).AsEnumerable()); + if (c < Config.MaxSyscallResultLines) + result.AppendLine($"{gi.Key.Trim(60)} [{productIds}]"); + if (bigList) + fullList!.AppendLine($"{gi.Key} [{productIds}]"); + c++; + } + await ctx.SendAutosplitMessageAsync(result.Append("```")).ConfigureAwait(false); + if (bigList) + { + await using var memoryStream = Config.MemoryStreamManager.GetStream(); + await using var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8); + await streamWriter.WriteAsync(fullList).ConfigureAwait(false); + await streamWriter.FlushAsync().ConfigureAwait(false); + memoryStream.Seek(0, SeekOrigin.Begin); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile($"{search}.txt", memoryStream).WithContent($"See attached file for full list of {groupedList.Count} entries")).ConfigureAwait(false); + } } else - await ctx.Channel.SendMessageAsync($"No information available for `{title}`").ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"No games found that use `{search}`").ConfigureAwait(false); + } + else + { + var result = new StringBuilder("Unknown entity name"); + var functions = await db.SyscallInfo.Select(sci => sci.Function).Distinct().ToListAsync().ConfigureAwait(false); + var substrFuncs = functions.Where(f => f.Contains(search, StringComparison.InvariantCultureIgnoreCase)); + var fuzzyFuncs = functions + .Select(f => (name: f, score: search.GetFuzzyCoefficientCached(f))) + .Where(i => i.score > 0.6) + .OrderByDescending(i => i.score) + .Select(i => i.name) + .ToList(); + functions = substrFuncs + .Concat(fuzzyFuncs) + .Distinct() + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); + var functionsFound = functions.Any(); + if (functionsFound) + { + result.AppendLine(", possible functions:```"); + foreach (var f in functions) + result.AppendLine(f); + result.AppendLine("```"); + } + await ctx.SendAutosplitMessageAsync(result).ConfigureAwait(false); } } + + [Command("rename"), RequiresBotModRole] + [Description("Provides an option to rename function call")] + public async Task Rename(CommandContext ctx, [Description("Old function name")] string oldFunctionName, [Description("New function name")] string newFunctionName) + { + await using var db = new ThumbnailDb(); + var oldMatches = await db.SyscallInfo.Where(sci => sci.Function == oldFunctionName).ToListAsync().ConfigureAwait(false); + if (oldMatches.Count == 0) + { + await ctx.Channel.SendMessageAsync($"Function `{oldFunctionName}` could not be found").ConfigureAwait(false); + await Search(ctx, oldFunctionName).ConfigureAwait(false); + return; + } + + if (oldMatches.Count > 1) + { + await ctx.Channel.SendMessageAsync("More than one matching function was found, I can't handle this right now 😔").ConfigureAwait(false); + await Search(ctx, oldFunctionName).ConfigureAwait(false); + return; + } + + var conflicts = await db.SyscallInfo.Where(sce => sce.Function == newFunctionName).AnyAsync().ConfigureAwait(false); + if (conflicts) + { + await ctx.Channel.SendMessageAsync($"There is already a function `{newFunctionName}`").ConfigureAwait(false); + await Search(ctx, newFunctionName).ConfigureAwait(false); + return; + } + + oldMatches[0].Function = newFunctionName; + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"Function `{oldFunctionName}` was successfully renamed to `{newFunctionName}`").ConfigureAwait(false); + } + + private static async Task ReturnSyscallsByGameAsync(CommandContext ctx, string productId) + { + productId = productId.ToUpperInvariant(); + await using var db = new ThumbnailDb(); + var title = db.Thumbnail.FirstOrDefault(t => t.ProductCode == productId)?.Name; + title = string.IsNullOrEmpty(title) ? productId : $"[{productId}] {title.Trim(40)}"; + var sysInfoList = db.SyscallToProductMap.AsNoTracking() + .Where(m => m.Product.ProductCode == productId) + .Select(m => m.SyscallInfo) + .Distinct() + .AsEnumerable() + .OrderBy(sci => sci.Function.TrimStart('_')) + .ToList(); + if (sysInfoList.Any()) + { + var result = new StringBuilder(); + foreach (var sci in sysInfoList) + result.AppendLine(sci.Function); + await using var memoryStream = Config.MemoryStreamManager.GetStream(); + await using var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8); + await streamWriter.WriteAsync(result).ConfigureAwait(false); + await streamWriter.FlushAsync().ConfigureAwait(false); + memoryStream.Seek(0, SeekOrigin.Begin); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithFile($"{productId} syscalls.txt", memoryStream).WithContent($"List of syscalls used by `{title}`")).ConfigureAwait(false); + } + else + await ctx.Channel.SendMessageAsync($"No information available for `{title}`").ConfigureAwait(false); + } } \ No newline at end of file diff --git a/CompatBot/Commands/Vision.cs b/CompatBot/Commands/Vision.cs index 7f86b02c..18f8964c 100644 --- a/CompatBot/Commands/Vision.cs +++ b/CompatBot/Commands/Vision.cs @@ -32,443 +32,442 @@ using RectangleF = SixLabors.ImageSharp.RectangleF; using Size = SixLabors.ImageSharp.Size; using SystemFonts = SixLabors.Fonts.SystemFonts; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Cooldown(1, 5, CooldownBucketType.Channel)] +internal sealed class Vision: BaseCommandModuleCustom { - [Cooldown(1, 5, CooldownBucketType.Channel)] - internal sealed class Vision: BaseCommandModuleCustom + static Vision() { - static Vision() - { - var list = new StringBuilder("Available system fonts:").AppendLine(); - foreach (var fontFamily in SystemFonts.Families) - list.AppendLine(fontFamily.Name); - Config.Log.Debug(list.ToString()); - } + var list = new StringBuilder("Available system fonts:").AppendLine(); + foreach (var fontFamily in SystemFonts.Families) + list.AppendLine(fontFamily.Name); + Config.Log.Debug(list.ToString()); + } - private static readonly Dictionary Reactions = new(StringComparer.OrdinalIgnoreCase) - { - ["cat"] = BotStats.GoodKot, - ["dog"] = BotStats.GoodDog, - ["hedgehog"] = new[] {"🦔"}, - ["flower"] = new[] {"🌷", "🌸", "🌹", "🌺", "🌼", "🥀", "💐", "🌻", "💮",}, - ["lizard"] = new[] {"🦎",}, - ["bird"] = new[] {"🐦", "🕊", "🦜", "🦆", "🦅", "🐓", "🐤", "🦩",}, - ["duck"] = new[] {"🦆",}, - ["eagle"] = new[] {"🦅",}, - ["turkey"] = new[] {"🦃",}, - ["turtle"] = new[] {"🐢",}, - ["bear"] = new[] {"🐻", "🐼",}, - ["panda"] = new[] {"🐼",}, - ["fox"] = new[] {"🦊",}, - ["pig"] = new[] {"🐷", "🐖", "🐽", "🐗",}, - ["primate"] = new[] {"🐵", "🐒", "🙊", "🙉", "🙈",}, - ["fish"] = new[] {"🐟", "🐠", "🐡", "🦈",}, - ["car"] = new[] {"🚗", "🏎", "🚙", "🚓", "🚘", "🚔",}, - ["banana"] = new[] {"🍌"}, - ["fruit"] = new[] {"🍇", "🍈", "🍉", "🍊", "🍍", "🍑", "🍒", "🍓", "🍋", "🍐", "🍎", "🍏", "🥑", "🥝", "🥭", "🍅",}, - ["vegetable"] = new[] {"🍠", "🍅", "🍆", "🥔", "🥕", "🥒",}, - ["watermelon"] = new[] {"🍉",}, - ["strawberry"] = new[] {"🍓",}, - }; + private static readonly Dictionary Reactions = new(StringComparer.OrdinalIgnoreCase) + { + ["cat"] = BotStats.GoodKot, + ["dog"] = BotStats.GoodDog, + ["hedgehog"] = new[] {"🦔"}, + ["flower"] = new[] {"🌷", "🌸", "🌹", "🌺", "🌼", "🥀", "💐", "🌻", "💮",}, + ["lizard"] = new[] {"🦎",}, + ["bird"] = new[] {"🐦", "🕊", "🦜", "🦆", "🦅", "🐓", "🐤", "🦩",}, + ["duck"] = new[] {"🦆",}, + ["eagle"] = new[] {"🦅",}, + ["turkey"] = new[] {"🦃",}, + ["turtle"] = new[] {"🐢",}, + ["bear"] = new[] {"🐻", "🐼",}, + ["panda"] = new[] {"🐼",}, + ["fox"] = new[] {"🦊",}, + ["pig"] = new[] {"🐷", "🐖", "🐽", "🐗",}, + ["primate"] = new[] {"🐵", "🐒", "🙊", "🙉", "🙈",}, + ["fish"] = new[] {"🐟", "🐠", "🐡", "🦈",}, + ["car"] = new[] {"🚗", "🏎", "🚙", "🚓", "🚘", "🚔",}, + ["banana"] = new[] {"🍌"}, + ["fruit"] = new[] {"🍇", "🍈", "🍉", "🍊", "🍍", "🍑", "🍒", "🍓", "🍋", "🍐", "🍎", "🍏", "🥑", "🥝", "🥭", "🍅",}, + ["vegetable"] = new[] {"🍠", "🍅", "🍆", "🥔", "🥕", "🥒",}, + ["watermelon"] = new[] {"🍉",}, + ["strawberry"] = new[] {"🍓",}, + }; - [Command("describe"), TriggersTyping] - [Description("Generates an image description from the attached image, or from the url")] - public Task Describe(CommandContext ctx, [RemainingText] string? imageUrl = null) - { - if (imageUrl?.StartsWith("tag") ?? false) - return Tag(ctx, imageUrl[3..].TrimStart()); - return Tag(ctx, imageUrl); - } + [Command("describe"), TriggersTyping] + [Description("Generates an image description from the attached image, or from the url")] + public Task Describe(CommandContext ctx, [RemainingText] string? imageUrl = null) + { + if (imageUrl?.StartsWith("tag") ?? false) + return Tag(ctx, imageUrl[3..].TrimStart()); + return Tag(ctx, imageUrl); + } - [Command("tag"), TriggersTyping] - [Description("Tags recognized objects in the image")] - public async Task Tag(CommandContext ctx, string? imageUrl = null) + [Command("tag"), TriggersTyping] + [Description("Tags recognized objects in the image")] + public async Task Tag(CommandContext ctx, string? imageUrl = null) + { + try { - try + imageUrl = await GetImageUrlAsync(ctx, imageUrl).ConfigureAwait(false); + if (string.IsNullOrEmpty(imageUrl) && ctx.Message.ReferencedMessage is { } msg) { - imageUrl = await GetImageUrlAsync(ctx, imageUrl).ConfigureAwait(false); - if (string.IsNullOrEmpty(imageUrl) && ctx.Message.ReferencedMessage is { } msg) - { - msg = await msg.Channel.GetMessageAsync(msg.Id).ConfigureAwait(false); - if (msg.Attachments.Any()) - imageUrl = GetImageAttachments(msg).FirstOrDefault()?.Url; - if (string.IsNullOrEmpty(imageUrl)) - imageUrl = GetImagesFromEmbeds(msg).FirstOrDefault(); - if (string.IsNullOrEmpty(imageUrl)) - imageUrl = await GetImageUrlAsync(ctx, msg.Content).ConfigureAwait(false); - } + msg = await msg.Channel.GetMessageAsync(msg.Id).ConfigureAwait(false); + if (msg.Attachments.Any()) + imageUrl = GetImageAttachments(msg).FirstOrDefault()?.Url; + if (string.IsNullOrEmpty(imageUrl)) + imageUrl = GetImagesFromEmbeds(msg).FirstOrDefault(); + if (string.IsNullOrEmpty(imageUrl)) + imageUrl = await GetImageUrlAsync(ctx, msg.Content).ConfigureAwait(false); + } - if (string.IsNullOrEmpty(imageUrl) || !Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute)) - { - await ctx.ReactWithAsync(Config.Reactions.Failure, "No proper image url was found").ConfigureAwait(false); - return; - } + if (string.IsNullOrEmpty(imageUrl) || !Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "No proper image url was found").ConfigureAwait(false); + return; + } - await using var imageStream = Config.MemoryStreamManager.GetStream(); - using (var httpClient = HttpClientFactory.Create()) - await using (var stream = await httpClient.GetStreamAsync(imageUrl).ConfigureAwait(false)) - await stream.CopyToAsync(imageStream).ConfigureAwait(false); - imageStream.Seek(0, SeekOrigin.Begin); + await using var imageStream = Config.MemoryStreamManager.GetStream(); + using (var httpClient = HttpClientFactory.Create()) + await using (var stream = await httpClient.GetStreamAsync(imageUrl).ConfigureAwait(false)) + await stream.CopyToAsync(imageStream).ConfigureAwait(false); + imageStream.Seek(0, SeekOrigin.Begin); #pragma warning disable VSTHRD103 - using var img = Image.Load(imageStream, out var imgFormat); + using var img = Image.Load(imageStream, out var imgFormat); #pragma warning restore VSTHRD103 + imageStream.Seek(0, SeekOrigin.Begin); + + //resize and shrink file size to get under azure limits + var quality = 90; + var resized = false; + if (img.Width > 4000 || img.Height > 4000) + { + img.Mutate(i => i.Resize(new ResizeOptions {Size = new(3840, 2160), Mode = ResizeMode.Min,})); + resized = true; + } + img.Mutate(i => i.AutoOrient()); + if (resized || imgFormat.Name != JpegFormat.Instance.Name) + { + imageStream.SetLength(0); + await img.SaveAsync(imageStream, new JpegEncoder {Quality = 90}).ConfigureAwait(false); imageStream.Seek(0, SeekOrigin.Begin); - - //resize and shrink file size to get under azure limits - var quality = 90; - var resized = false; - if (img.Width > 4000 || img.Height > 4000) + } + else + { + try { - img.Mutate(i => i.Resize(new ResizeOptions {Size = new(3840, 2160), Mode = ResizeMode.Min,})); - resized = true; + quality = img.Metadata.GetJpegMetadata().Quality; } - img.Mutate(i => i.AutoOrient()); - if (resized || imgFormat.Name != JpegFormat.Instance.Name) + catch (Exception ex) { - imageStream.SetLength(0); - await img.SaveAsync(imageStream, new JpegEncoder {Quality = 90}).ConfigureAwait(false); - imageStream.Seek(0, SeekOrigin.Begin); + Config.Log.Warn(ex); } - else + } + if (imageStream.Length > 4 * 1024 * 1024) + { + quality -= 5; + imageStream.SetLength(0); + await img.SaveAsync(imageStream, new JpegEncoder {Quality = quality}).ConfigureAwait(false); + imageStream.Seek(0, SeekOrigin.Begin); + } + + var client = new ComputerVisionClient(new ApiKeyServiceClientCredentials(Config.AzureComputerVisionKey)) {Endpoint = Config.AzureComputerVisionEndpoint}; + var result = await client.AnalyzeImageInStreamAsync( + imageStream, + new List { - try - { - quality = img.Metadata.GetJpegMetadata().Quality; - } - catch (Exception ex) - { - Config.Log.Warn(ex); - } + VisualFeatureTypes.Objects, // https://docs.microsoft.com/en-us/azure/cognitive-services/computer-vision/concept-object-detection + VisualFeatureTypes.Description, // https://docs.microsoft.com/en-us/azure/cognitive-services/computer-vision/concept-describing-images + VisualFeatureTypes.Adult, // https://docs.microsoft.com/en-us/azure/cognitive-services/computer-vision/concept-detecting-adult-content + }, + cancellationToken: Config.Cts.Token + ).ConfigureAwait(false); + var description = GetDescription(result.Description, result.Adult); + var objects = result.Objects + .OrderBy(c => c.Rectangle.Y) + .ThenBy(c => c.Confidence) + .ToList(); + var scale = Math.Max(1.0f, img.Width / 400.0f); + if (objects.Count > 0 && !result.Adult.IsAdultContent && !result.Adult.IsGoryContent) + { + var analyzer = new ColorThief(); + List palette = new(objects.Count); + foreach (var obj in objects) + { + var r = obj.Rectangle; + await using var tmpStream = Config.MemoryStreamManager.GetStream(); + using var boxCopy = img.Clone(i => i.Crop(new(r.X, r.Y, r.W, r.H))); + await boxCopy.SaveAsBmpAsync(tmpStream).ConfigureAwait(false); + tmpStream.Seek(0, SeekOrigin.Begin); + + //using var b = new Bitmap(tmpStream); + var b = Image.Load(tmpStream); + var dominantColor = ColorGetter.GetDominentColor(b); + palette.Add(dominantColor); } - if (imageStream.Length > 4 * 1024 * 1024) + var complementaryPalette = palette.Select(c => c.GetComplementary()).ToList(); + var tmpP = new List(); + var tmpCp = new List(); + var uniqueCp = new HashSet(); + for (var i = 0; i < complementaryPalette.Count; i++) + if (uniqueCp.Add(complementaryPalette[i])) + { + tmpP.Add(palette[i]); + tmpCp.Add(complementaryPalette[i]); + } + palette = tmpP; + complementaryPalette = tmpCp; + + Config.Log.Debug($"Palette : {string.Join(' ', palette.Select(c => $"#{c.ToHex()}"))}"); + Config.Log.Debug($"Complementary: {string.Join(' ', complementaryPalette.Select(c => $"#{c.ToHex()}"))}"); + + if ((string.IsNullOrEmpty(Config.PreferredFontFamily) || !SystemFonts.TryGet(Config.PreferredFontFamily, out var fontFamily)) + && !SystemFonts.TryGet("Roboto", out fontFamily) + && !SystemFonts.TryGet("Droid Sans", out fontFamily) + && !SystemFonts.TryGet("DejaVu Sans", out fontFamily) + && !SystemFonts.TryGet("Sans Serif", out fontFamily) + && !SystemFonts.TryGet("Calibri", out fontFamily) + && !SystemFonts.TryGet("Verdana", out fontFamily)) { - quality -= 5; - imageStream.SetLength(0); - await img.SaveAsync(imageStream, new JpegEncoder {Quality = quality}).ConfigureAwait(false); - imageStream.Seek(0, SeekOrigin.Begin); + Config.Log.Warn("Failed to find any suitable font. Available system fonts:\n" + string.Join(Environment.NewLine, SystemFonts.Families.Select(f => f.Name))); + fontFamily = SystemFonts.Families.FirstOrDefault(f => f.Name.Contains("sans", StringComparison.OrdinalIgnoreCase)); } - - var client = new ComputerVisionClient(new ApiKeyServiceClientCredentials(Config.AzureComputerVisionKey)) {Endpoint = Config.AzureComputerVisionEndpoint}; - var result = await client.AnalyzeImageInStreamAsync( - imageStream, - new List - { - VisualFeatureTypes.Objects, // https://docs.microsoft.com/en-us/azure/cognitive-services/computer-vision/concept-object-detection - VisualFeatureTypes.Description, // https://docs.microsoft.com/en-us/azure/cognitive-services/computer-vision/concept-describing-images - VisualFeatureTypes.Adult, // https://docs.microsoft.com/en-us/azure/cognitive-services/computer-vision/concept-detecting-adult-content - }, - cancellationToken: Config.Cts.Token - ).ConfigureAwait(false); - var description = GetDescription(result.Description, result.Adult); - var objects = result.Objects - .OrderBy(c => c.Rectangle.Y) - .ThenBy(c => c.Confidence) - .ToList(); - var scale = Math.Max(1.0f, img.Width / 400.0f); - if (objects.Count > 0 && !result.Adult.IsAdultContent && !result.Adult.IsGoryContent) + Config.Log.Debug($"Selected font: {fontFamily.Name}"); + var font = fontFamily.CreateFont(10 * scale, FontStyle.Regular); + var graphicsOptions = new GraphicsOptions { - var analyzer = new ColorThief(); - List palette = new(objects.Count); - foreach (var obj in objects) + Antialias = true, + ColorBlendingMode = PixelColorBlendingMode.Normal, + }; + var bgGop = new GraphicsOptions + { + ColorBlendingMode = PixelColorBlendingMode.Screen, + }; + var fgGop = new GraphicsOptions + { + ColorBlendingMode = PixelColorBlendingMode.Multiply, + }; + var shapeDrawingOptions = new DrawingOptions {GraphicsOptions = graphicsOptions}; + var bgDrawingOptions = new DrawingOptions {GraphicsOptions = bgGop,}; + var drawnBoxes = new List(objects.Count); + for (var i = 0; i < objects.Count; i++) + { + var obj = objects[i]; + var label = $"{obj.ObjectProperty.FixKot()} ({obj.Confidence:P1})"; + var r = obj.Rectangle; + var color = palette[i % palette.Count]; + var complementaryColor = complementaryPalette[i % complementaryPalette.Count]; + var textOptions = new TextOptions(font) { - var r = obj.Rectangle; - await using var tmpStream = Config.MemoryStreamManager.GetStream(); - using var boxCopy = img.Clone(i => i.Crop(new(r.X, r.Y, r.W, r.H))); - await boxCopy.SaveAsBmpAsync(tmpStream).ConfigureAwait(false); - tmpStream.Seek(0, SeekOrigin.Begin); - - //using var b = new Bitmap(tmpStream); - var b = Image.Load(tmpStream); - var dominantColor = ColorGetter.GetDominentColor(b); - palette.Add(dominantColor); - } - var complementaryPalette = palette.Select(c => c.GetComplementary()).ToList(); - var tmpP = new List(); - var tmpCp = new List(); - var uniqueCp = new HashSet(); - for (var i = 0; i < complementaryPalette.Count; i++) - if (uniqueCp.Add(complementaryPalette[i])) - { - tmpP.Add(palette[i]); - tmpCp.Add(complementaryPalette[i]); - } - palette = tmpP; - complementaryPalette = tmpCp; - - Config.Log.Debug($"Palette : {string.Join(' ', palette.Select(c => $"#{c.ToHex()}"))}"); - Config.Log.Debug($"Complementary: {string.Join(' ', complementaryPalette.Select(c => $"#{c.ToHex()}"))}"); - - if ((string.IsNullOrEmpty(Config.PreferredFontFamily) || !SystemFonts.TryGet(Config.PreferredFontFamily, out var fontFamily)) - && !SystemFonts.TryGet("Roboto", out fontFamily) - && !SystemFonts.TryGet("Droid Sans", out fontFamily) - && !SystemFonts.TryGet("DejaVu Sans", out fontFamily) - && !SystemFonts.TryGet("Sans Serif", out fontFamily) - && !SystemFonts.TryGet("Calibri", out fontFamily) - && !SystemFonts.TryGet("Verdana", out fontFamily)) - { - Config.Log.Warn("Failed to find any suitable font. Available system fonts:\n" + string.Join(Environment.NewLine, SystemFonts.Families.Select(f => f.Name))); - fontFamily = SystemFonts.Families.FirstOrDefault(f => f.Name.Contains("sans", StringComparison.OrdinalIgnoreCase)); - } - Config.Log.Debug($"Selected font: {fontFamily.Name}"); - var font = fontFamily.CreateFont(10 * scale, FontStyle.Regular); - var graphicsOptions = new GraphicsOptions - { - Antialias = true, - ColorBlendingMode = PixelColorBlendingMode.Normal, - }; - var bgGop = new GraphicsOptions - { - ColorBlendingMode = PixelColorBlendingMode.Screen, - }; - var fgGop = new GraphicsOptions - { - ColorBlendingMode = PixelColorBlendingMode.Multiply, - }; - var shapeDrawingOptions = new DrawingOptions {GraphicsOptions = graphicsOptions}; - var bgDrawingOptions = new DrawingOptions {GraphicsOptions = bgGop,}; - var drawnBoxes = new List(objects.Count); - for (var i = 0; i < objects.Count; i++) - { - var obj = objects[i]; - var label = $"{obj.ObjectProperty.FixKot()} ({obj.Confidence:P1})"; - var r = obj.Rectangle; - var color = palette[i % palette.Count]; - var complementaryColor = complementaryPalette[i % complementaryPalette.Count]; - var textOptions = new TextOptions(font) - { - KerningMode = KerningMode.Normal, + KerningMode = KerningMode.Normal, #if LABELS_INSIDE WrapTextWidth = r.W - 10, #endif - }; - var textDrawingOptions = new DrawingOptions {GraphicsOptions = fgGop/*, TextOptions = textOptions*/}; - //var brush = Brushes.Solid(Color.Black); - //var pen = Pens.Solid(color, 2); - var textBox = TextMeasurer.Measure(label, textOptions); + }; + var textDrawingOptions = new DrawingOptions {GraphicsOptions = fgGop/*, TextOptions = textOptions*/}; + //var brush = Brushes.Solid(Color.Black); + //var pen = Pens.Solid(color, 2); + var textBox = TextMeasurer.Measure(label, textOptions); #if LABELS_INSIDE var textHeightScale = (int)Math.Ceiling(textBox.Width / Math.Min(img.Width - r.X - 10 - 4 * scale, r.W - 4 * scale)); #endif - // object bounding box - try - { - img.Mutate(ipc => ipc.Draw(shapeDrawingOptions, complementaryColor, scale, new RectangleF(r.X, r.Y, r.W, r.H))); - img.Mutate(ipc => ipc.Draw(shapeDrawingOptions, color, scale, new RectangleF(r.X + scale, r.Y + scale, r.W - 2 * scale, r.H - 2 * scale))); - } - catch (Exception ex) - { - Config.Log.Error(ex, "Failed to draw object bounding box"); - } + // object bounding box + try + { + img.Mutate(ipc => ipc.Draw(shapeDrawingOptions, complementaryColor, scale, new RectangleF(r.X, r.Y, r.W, r.H))); + img.Mutate(ipc => ipc.Draw(shapeDrawingOptions, color, scale, new RectangleF(r.X + scale, r.Y + scale, r.W - 2 * scale, r.H - 2 * scale))); + } + catch (Exception ex) + { + Config.Log.Error(ex, "Failed to draw object bounding box"); + } - // label bounding box - var bboxBorder = scale; + // label bounding box + var bboxBorder = scale; #if LABELS_INSIDE var bgBox = new RectangleF(r.X + 2 * scale, r.Y + 2 * scale, Math.Min(textBox.Width + 2 * (bboxBorder + scale), r.W - 4 * scale), textBox.Height * textHeightScale + 2 * (bboxBorder + scale)); #else - var bgBox = new RectangleF(r.X, r.Y - textBox.Height - 2 * bboxBorder - scale, textBox.Width + 2 * bboxBorder, textBox.Height + 2 * bboxBorder); + var bgBox = new RectangleF(r.X, r.Y - textBox.Height - 2 * bboxBorder - scale, textBox.Width + 2 * bboxBorder, textBox.Height + 2 * bboxBorder); #endif - while (drawnBoxes.Any(b => b.IntersectsWith(bgBox))) - { - var pb = drawnBoxes.First(b => b.IntersectsWith(bgBox)); - bgBox.Y = pb.Bottom; - } - if (bgBox.Width < 20) - bgBox.Width = 20 * scale; - if (bgBox.Height < 20) - bgBox.Height = 20 * scale; - if (bgBox.X < 0) - bgBox.X = 0; - if (bgBox.Y < 0) - bgBox.Y = 0; - if (bgBox.X + bgBox.Width > img.Width) - bgBox.X = img.Width - bgBox.Width; - if (bgBox.Y + bgBox.Height > img.Height) - bgBox.Y = img.Height - bgBox.Height; - drawnBoxes.Add(bgBox); - var textBoxColor = complementaryColor; - var textColor = color; - try - { - img.Mutate(ipc => ipc.Fill(bgDrawingOptions, textBoxColor, bgBox)); - img.Mutate(ipc => ipc.GaussianBlur(10 * scale, Rectangle.Round(bgBox))); - } - catch (Exception ex) - { - Config.Log.Error(ex, "Failed to draw label bounding box"); - } - - // label text - try - { - img.Mutate(ipc => ipc.DrawText(textDrawingOptions, label, font, textColor, new(bgBox.X + bboxBorder, bgBox.Y + bboxBorder))); - } - catch (Exception ex) - { - Config.Log.Error(ex, "Failed to generate tag label"); - } - } - await using var resultStream = Config.MemoryStreamManager.GetStream(); - quality = 95; - do + while (drawnBoxes.Any(b => b.IntersectsWith(bgBox))) { - resultStream.SetLength(0); - await img.SaveAsync(resultStream, new JpegEncoder {Quality = 95}).ConfigureAwait(false); - resultStream.Seek(0, SeekOrigin.Begin); - quality--; - } while (resultStream.Length > ctx.GetAttachmentSizeLimit()); - var attachmentFname = Path.GetFileNameWithoutExtension(imageUrl) + "_tagged.jpg"; - if (result.Adult.IsRacyContent && !attachmentFname.StartsWith("SPOILER_")) - attachmentFname = "SPOILER_" + attachmentFname; - var messageBuilder = new DiscordMessageBuilder() - .WithContent(description) - .WithFile(attachmentFname, resultStream); - if (ctx.Message.ReferencedMessage is { } ogRef) - messageBuilder.WithReply(ogRef.Id); - var respondMsg = await ctx.Channel.SendMessageAsync(messageBuilder).ConfigureAwait(false); - var tags = result.Objects.Select(o => o.ObjectProperty).Concat(result.Description.Tags).Distinct().ToList(); - Config.Log.Info( - $"Tags for image {imageUrl}: {string.Join(", ", tags)}. Adult info: a={result.Adult.AdultScore:0.000}, r={result.Adult.RacyScore:0.000}, g={result.Adult.GoreScore:0.000}"); - if (result.Adult.IsRacyContent) - await respondMsg.ReactWithAsync(DiscordEmoji.FromUnicode("😳")).ConfigureAwait(false); - await ReactToTagsAsync(respondMsg, tags).ConfigureAwait(false); + var pb = drawnBoxes.First(b => b.IntersectsWith(bgBox)); + bgBox.Y = pb.Bottom; + } + if (bgBox.Width < 20) + bgBox.Width = 20 * scale; + if (bgBox.Height < 20) + bgBox.Height = 20 * scale; + if (bgBox.X < 0) + bgBox.X = 0; + if (bgBox.Y < 0) + bgBox.Y = 0; + if (bgBox.X + bgBox.Width > img.Width) + bgBox.X = img.Width - bgBox.Width; + if (bgBox.Y + bgBox.Height > img.Height) + bgBox.Y = img.Height - bgBox.Height; + drawnBoxes.Add(bgBox); + var textBoxColor = complementaryColor; + var textColor = color; + try + { + img.Mutate(ipc => ipc.Fill(bgDrawingOptions, textBoxColor, bgBox)); + img.Mutate(ipc => ipc.GaussianBlur(10 * scale, Rectangle.Round(bgBox))); + } + catch (Exception ex) + { + Config.Log.Error(ex, "Failed to draw label bounding box"); + } + + // label text + try + { + img.Mutate(ipc => ipc.DrawText(textDrawingOptions, label, font, textColor, new(bgBox.X + bboxBorder, bgBox.Y + bboxBorder))); + } + catch (Exception ex) + { + Config.Log.Error(ex, "Failed to generate tag label"); + } } - else + await using var resultStream = Config.MemoryStreamManager.GetStream(); + quality = 95; + do { - var msgBuilder = new DiscordMessageBuilder() - .WithContent(description); - if (ctx.Message.ReferencedMessage is { } ogRef) - msgBuilder.WithReply(ogRef.Id); - await ctx.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); - if (result.Adult.IsAdultContent) - await ctx.Message.ReactWithAsync(DiscordEmoji.FromUnicode("🔞")).ConfigureAwait(false); - if (result.Adult.IsRacyContent) - await ctx.Message.ReactWithAsync(DiscordEmoji.FromUnicode("😳")).ConfigureAwait(false); - if (result.Adult.IsGoryContent) - await ctx.Message.ReactWithAsync(DiscordEmoji.FromUnicode("🆖")).ConfigureAwait(false); - Config.Log.Info($"Adult info for image {imageUrl}: a={result.Adult.AdultScore:0.000}, r={result.Adult.RacyScore:0.000}, g={result.Adult.GoreScore:0.000}"); - await ReactToTagsAsync(ctx.Message, result.Description.Tags).ConfigureAwait(false); - } + resultStream.SetLength(0); + await img.SaveAsync(resultStream, new JpegEncoder {Quality = 95}).ConfigureAwait(false); + resultStream.Seek(0, SeekOrigin.Begin); + quality--; + } while (resultStream.Length > ctx.GetAttachmentSizeLimit()); + var attachmentFname = Path.GetFileNameWithoutExtension(imageUrl) + "_tagged.jpg"; + if (result.Adult.IsRacyContent && !attachmentFname.StartsWith("SPOILER_")) + attachmentFname = "SPOILER_" + attachmentFname; + var messageBuilder = new DiscordMessageBuilder() + .WithContent(description) + .WithFile(attachmentFname, resultStream); + if (ctx.Message.ReferencedMessage is { } ogRef) + messageBuilder.WithReply(ogRef.Id); + var respondMsg = await ctx.Channel.SendMessageAsync(messageBuilder).ConfigureAwait(false); + var tags = result.Objects.Select(o => o.ObjectProperty).Concat(result.Description.Tags).Distinct().ToList(); + Config.Log.Info( + $"Tags for image {imageUrl}: {string.Join(", ", tags)}. Adult info: a={result.Adult.AdultScore:0.000}, r={result.Adult.RacyScore:0.000}, g={result.Adult.GoreScore:0.000}"); + if (result.Adult.IsRacyContent) + await respondMsg.ReactWithAsync(DiscordEmoji.FromUnicode("😳")).ConfigureAwait(false); + await ReactToTagsAsync(respondMsg, tags).ConfigureAwait(false); } - catch (ComputerVisionErrorResponseException cve) when (cve.Response.StatusCode == HttpStatusCode.ServiceUnavailable) + else { - Config.Log.Warn(cve, "Computer Vision is broken"); - await ctx.Channel.SendMessageAsync("Azure services are temporarily unavailable, please try in an hour or so").ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to tag objects in an image"); - await ctx.Channel.SendMessageAsync("Can't do anything with this image").ConfigureAwait(false); + var msgBuilder = new DiscordMessageBuilder() + .WithContent(description); + if (ctx.Message.ReferencedMessage is { } ogRef) + msgBuilder.WithReply(ogRef.Id); + await ctx.Channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + if (result.Adult.IsAdultContent) + await ctx.Message.ReactWithAsync(DiscordEmoji.FromUnicode("🔞")).ConfigureAwait(false); + if (result.Adult.IsRacyContent) + await ctx.Message.ReactWithAsync(DiscordEmoji.FromUnicode("😳")).ConfigureAwait(false); + if (result.Adult.IsGoryContent) + await ctx.Message.ReactWithAsync(DiscordEmoji.FromUnicode("🆖")).ConfigureAwait(false); + Config.Log.Info($"Adult info for image {imageUrl}: a={result.Adult.AdultScore:0.000}, r={result.Adult.RacyScore:0.000}, g={result.Adult.GoreScore:0.000}"); + await ReactToTagsAsync(ctx.Message, result.Description.Tags).ConfigureAwait(false); } } - - internal static IEnumerable GetImagesFromEmbeds(DiscordMessage msg) + catch (ComputerVisionErrorResponseException cve) when (cve.Response.StatusCode == HttpStatusCode.ServiceUnavailable) { - foreach (var embed in msg.Embeds) - { - if (embed.Image?.Url?.ToString() is string url) - yield return url; - else if (embed.Thumbnail?.Url?.ToString() is string thumbUrl) - yield return thumbUrl; - } + Config.Log.Warn(cve, "Computer Vision is broken"); + await ctx.Channel.SendMessageAsync("Azure services are temporarily unavailable, please try in an hour or so").ConfigureAwait(false); } + catch (Exception e) + { + Config.Log.Error(e, "Failed to tag objects in an image"); + await ctx.Channel.SendMessageAsync("Can't do anything with this image").ConfigureAwait(false); + } + } - internal static IEnumerable GetImageAttachments(DiscordMessage message) - => message.Attachments.Where(a => + internal static IEnumerable GetImagesFromEmbeds(DiscordMessage msg) + { + foreach (var embed in msg.Embeds) + { + if (embed.Image?.Url?.ToString() is string url) + yield return url; + else if (embed.Thumbnail?.Url?.ToString() is string thumbUrl) + yield return thumbUrl; + } + } + + internal static IEnumerable GetImageAttachments(DiscordMessage message) + => message.Attachments.Where(a => a.FileName.EndsWith(".jpg", StringComparison.InvariantCultureIgnoreCase) || a.FileName.EndsWith(".png", StringComparison.InvariantCultureIgnoreCase) || a.FileName.EndsWith(".jpeg", StringComparison.InvariantCultureIgnoreCase) - //|| a.FileName.EndsWith(".webp", StringComparison.InvariantCultureIgnoreCase) - ); + //|| a.FileName.EndsWith(".webp", StringComparison.InvariantCultureIgnoreCase) + ); - private static string GetDescription(ImageDescriptionDetails description, AdultInfo adultInfo) + private static string GetDescription(ImageDescriptionDetails description, AdultInfo adultInfo) + { + var captions = description.Captions.OrderByDescending(c => c.Confidence).ToList(); + string msg; + if (captions.Any()) { - var captions = description.Captions.OrderByDescending(c => c.Confidence).ToList(); - string msg; - if (captions.Any()) + var confidence = captions[0].Confidence switch { - var confidence = captions[0].Confidence switch - { - > 0.98 => "It is", - > 0.95 => "I'm pretty sure it is", - > 0.9 => "I'm quite sure it is", - > 0.8 => "I think it's", - > 0.5 => "I'm not very smart, so my best guess is that it's", - _ => "Ugh, idk? Might be", - }; - msg = $"{confidence} {captions[0].Text.FixKot()}"; + > 0.98 => "It is", + > 0.95 => "I'm pretty sure it is", + > 0.9 => "I'm quite sure it is", + > 0.8 => "I think it's", + > 0.5 => "I'm not very smart, so my best guess is that it's", + _ => "Ugh, idk? Might be", + }; + msg = $"{confidence} {captions[0].Text.FixKot()}"; #if DEBUG - msg += $" [{captions[0].Confidence * 100:0.00}%]"; - if (captions.Count > 1) - { - msg += "\nHowever, here are more guesses:\n"; - msg += string.Join('\n', captions.Skip(1).Select(c => $"{c.Text} [{c.Confidence * 100:0.00}%]")); - msg += "\n"; - } -#endif - } - else - msg = "An image so weird, I have no words to describe it"; -#if DEBUG - msg += $" (Adult: {adultInfo.AdultScore * 100:0.00}%, racy: {adultInfo.RacyScore * 100:0.00}%, gore: {adultInfo.GoreScore * 100:0.00}%)"; -#endif - return msg; - } - - private static async Task ReactToTagsAsync(DiscordMessage reactMsg, IEnumerable tags) - { - foreach (var t in tags.Distinct(StringComparer.OrdinalIgnoreCase)) - if (Reactions.TryGetValue(t, out var emojiList)) - await reactMsg.ReactWithAsync(DiscordEmoji.FromUnicode(emojiList[new Random().Next(emojiList.Length)])).ConfigureAwait(false); - } - - private static async Task GetImageUrlAsync(CommandContext ctx, string? imageUrl) - { - var reactMsg = ctx.Message; - if (GetImageAttachments(reactMsg).FirstOrDefault() is DiscordAttachment attachment) - imageUrl = attachment.Url; - imageUrl = imageUrl?.Trim() ?? ""; - if (!string.IsNullOrEmpty(imageUrl) - && imageUrl.StartsWith('<') - && imageUrl.EndsWith('>')) - imageUrl = imageUrl[1..^1]; - if (!Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute)) + msg += $" [{captions[0].Confidence * 100:0.00}%]"; + if (captions.Count > 1) { - var str = imageUrl.ToLowerInvariant(); - if ((str.StartsWith("this") - || str.StartsWith("that") - || str.StartsWith("last") - || str.StartsWith("previous") - || str.StartsWith("^")) - && ctx.Channel.PermissionsFor(ctx.Client.GetMember(ctx.Guild, ctx.Client.CurrentUser)).HasPermission(Permissions.ReadMessageHistory)) - try - { - var previousMessages = (await ctx.Channel.GetMessagesBeforeCachedAsync(ctx.Message.Id, 10).ConfigureAwait(false))!; - imageUrl = ( - from m in previousMessages - where m.Attachments?.Count > 0 - from a in GetImageAttachments(m) - select a.Url - ).FirstOrDefault(); - if (string.IsNullOrEmpty(imageUrl)) - { - var selectedUrl = ( - from m in previousMessages - where m.Embeds?.Count > 0 - from e in m.Embeds - let url = e.Image?.Url ?? e.Image?.ProxyUrl ?? e.Thumbnail?.Url ?? e.Thumbnail?.ProxyUrl - select url - ).FirstOrDefault(); - imageUrl = selectedUrl?.ToString(); - } - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to get link to the previously posted image"); - //await ctx.Channel.SendMessageAsync("Sorry chief, can't find any images in the recent posts").ConfigureAwait(false); - } + msg += "\nHowever, here are more guesses:\n"; + msg += string.Join('\n', captions.Skip(1).Select(c => $"{c.Text} [{c.Confidence * 100:0.00}%]")); + msg += "\n"; } - return imageUrl; +#endif } + else + msg = "An image so weird, I have no words to describe it"; +#if DEBUG + msg += $" (Adult: {adultInfo.AdultScore * 100:0.00}%, racy: {adultInfo.RacyScore * 100:0.00}%, gore: {adultInfo.GoreScore * 100:0.00}%)"; +#endif + return msg; } -} + + private static async Task ReactToTagsAsync(DiscordMessage reactMsg, IEnumerable tags) + { + foreach (var t in tags.Distinct(StringComparer.OrdinalIgnoreCase)) + if (Reactions.TryGetValue(t, out var emojiList)) + await reactMsg.ReactWithAsync(DiscordEmoji.FromUnicode(emojiList[new Random().Next(emojiList.Length)])).ConfigureAwait(false); + } + + private static async Task GetImageUrlAsync(CommandContext ctx, string? imageUrl) + { + var reactMsg = ctx.Message; + if (GetImageAttachments(reactMsg).FirstOrDefault() is DiscordAttachment attachment) + imageUrl = attachment.Url; + imageUrl = imageUrl?.Trim() ?? ""; + if (!string.IsNullOrEmpty(imageUrl) + && imageUrl.StartsWith('<') + && imageUrl.EndsWith('>')) + imageUrl = imageUrl[1..^1]; + if (!Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute)) + { + var str = imageUrl.ToLowerInvariant(); + if ((str.StartsWith("this") + || str.StartsWith("that") + || str.StartsWith("last") + || str.StartsWith("previous") + || str.StartsWith("^")) + && ctx.Channel.PermissionsFor(ctx.Client.GetMember(ctx.Guild, ctx.Client.CurrentUser)).HasPermission(Permissions.ReadMessageHistory)) + try + { + var previousMessages = (await ctx.Channel.GetMessagesBeforeCachedAsync(ctx.Message.Id, 10).ConfigureAwait(false))!; + imageUrl = ( + from m in previousMessages + where m.Attachments?.Count > 0 + from a in GetImageAttachments(m) + select a.Url + ).FirstOrDefault(); + if (string.IsNullOrEmpty(imageUrl)) + { + var selectedUrl = ( + from m in previousMessages + where m.Embeds?.Count > 0 + from e in m.Embeds + let url = e.Image?.Url ?? e.Image?.ProxyUrl ?? e.Thumbnail?.Url ?? e.Thumbnail?.ProxyUrl + select url + ).FirstOrDefault(); + imageUrl = selectedUrl?.ToString(); + } + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to get link to the previously posted image"); + //await ctx.Channel.SendMessageAsync("Sorry chief, can't find any images in the recent posts").ConfigureAwait(false); + } + } + return imageUrl; + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Warnings.ListGroup.cs b/CompatBot/Commands/Warnings.ListGroup.cs index 662da185..74364291 100644 --- a/CompatBot/Commands/Warnings.ListGroup.cs +++ b/CompatBot/Commands/Warnings.ListGroup.cs @@ -12,209 +12,208 @@ using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.CommandsNext.Converters; using DSharpPlus.Entities; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +internal sealed partial class Warnings { - internal sealed partial class Warnings + [Group("list"), Aliases("show")] + [Description("Allows to list warnings in various ways. Users can only see their own warnings.")] + public class ListGroup : BaseCommandModuleCustom { - [Group("list"), Aliases("show")] - [Description("Allows to list warnings in various ways. Users can only see their own warnings.")] - public class ListGroup : BaseCommandModuleCustom + [GroupCommand, Priority(10)] + [Description("Show warning list for a user. Default is to show warning list for yourself")] + public async Task List(CommandContext ctx, [Description("Discord user to list warnings for")] DiscordUser user) { - [GroupCommand, Priority(10)] - [Description("Show warning list for a user. Default is to show warning list for yourself")] - public async Task List(CommandContext ctx, [Description("Discord user to list warnings for")] DiscordUser user) - { - if (await CheckListPermissionAsync(ctx, user.Id).ConfigureAwait(false)) - await ListUserWarningsAsync(ctx.Client, ctx.Message, user.Id, user.Username.Sanitize(), false); - } + if (await CheckListPermissionAsync(ctx, user.Id).ConfigureAwait(false)) + await ListUserWarningsAsync(ctx.Client, ctx.Message, user.Id, user.Username.Sanitize(), false); + } - [GroupCommand] - public async Task List(CommandContext ctx, [Description("Id of the user to list warnings for")] ulong userId) - { - if (await CheckListPermissionAsync(ctx, userId).ConfigureAwait(false)) - await ListUserWarningsAsync(ctx.Client, ctx.Message, userId, $"<@{userId}>", false); - } + [GroupCommand] + public async Task List(CommandContext ctx, [Description("Id of the user to list warnings for")] ulong userId) + { + if (await CheckListPermissionAsync(ctx, userId).ConfigureAwait(false)) + await ListUserWarningsAsync(ctx.Client, ctx.Message, userId, $"<@{userId}>", false); + } - [GroupCommand] - [Description("List your own warning list")] - public async Task List(CommandContext ctx) - => await List(ctx, ctx.Message.Author).ConfigureAwait(false); + [GroupCommand] + [Description("List your own warning list")] + public async Task List(CommandContext ctx) + => await List(ctx, ctx.Message.Author).ConfigureAwait(false); - [Command("users"), Aliases("top"), RequiresBotModRole, TriggersTyping] - [Description("List users with warnings, sorted from most warned to least")] - public async Task Users(CommandContext ctx, [Description("Optional number of items to show. Default is 10")] int number = 10) - { - try - { - if (number < 1) - number = 10; - var table = new AsciiTable( - new AsciiColumn("Username", maxWidth: 24), - new AsciiColumn("User ID", disabled: !ctx.Channel.IsPrivate, alignToRight: true), - new AsciiColumn("Count", alignToRight: true), - new AsciiColumn("All time", alignToRight: true) - ); - await using var db = new BotDb(); - var query = from warn in db.Warning.AsEnumerable() - group warn by warn.DiscordId - into userGroup - let row = new {discordId = userGroup.Key, count = userGroup.Count(w => !w.Retracted), total = userGroup.Count()} - orderby row.count descending - select row; - foreach (var row in query.Take(number)) - { - var username = await ctx.GetUserNameAsync(row.discordId).ConfigureAwait(false); - table.Add(username, row.discordId.ToString(), row.count.ToString(), row.total.ToString()); - } - await ctx.SendAutosplitMessageAsync(new StringBuilder("Warning count per user:").Append(table)).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - await ctx.ReactWithAsync(Config.Reactions.Failure, "SQL query for this command is broken at the moment", true).ConfigureAwait(false); - } - } - - [Command("mods"), Aliases("mtop"), RequiresBotModRole, TriggersTyping] - [Description("List bot mods, sorted by the number of warnings issued")] - public async Task Mods(CommandContext ctx, [Description("Optional number of items to show. Default is 10")] int number = 10) - { - try - { - if (number < 1) - number = 10; - var table = new AsciiTable( - new AsciiColumn("Username", maxWidth: 24), - new AsciiColumn("Issuer ID", disabled: !ctx.Channel.IsPrivate, alignToRight: true), - new AsciiColumn("Warnings given", alignToRight: true), - new AsciiColumn("Including retracted", alignToRight: true) - ); - await using var db = new BotDb(); - var query = from warn in db.Warning.AsEnumerable() - group warn by warn.IssuerId - into modGroup - let row = new {userId = modGroup.Key, count = modGroup.Count(w => !w.Retracted), total = modGroup.Count()} - orderby row.count descending - select row; - foreach (var row in query.Take(number)) - { - var username = await ctx.GetUserNameAsync(row.userId).ConfigureAwait(false); - if (username is null or "") - username = "Unknown"; - table.Add(username, row.userId.ToString(), row.count.ToString(), row.total.ToString()); - } - await ctx.SendAutosplitMessageAsync(new StringBuilder("Warnings issued per bot mod:").Append(table)).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - await ctx.ReactWithAsync(Config.Reactions.Failure, "SQL query for this command is broken at the moment", true).ConfigureAwait(false); - } - } - - [Command("by"), RequiresBotModRole] - [Description("Shows warnings issued by the specified moderator")] - public async Task By(CommandContext ctx, ulong moderatorId, [Description("Optional number of items to show. Default is 10")] int number = 10) + [Command("users"), Aliases("top"), RequiresBotModRole, TriggersTyping] + [Description("List users with warnings, sorted from most warned to least")] + public async Task Users(CommandContext ctx, [Description("Optional number of items to show. Default is 10")] int number = 10) + { + try { if (number < 1) number = 10; var table = new AsciiTable( - new AsciiColumn("ID", alignToRight: true), new AsciiColumn("Username", maxWidth: 24), new AsciiColumn("User ID", disabled: !ctx.Channel.IsPrivate, alignToRight: true), - new AsciiColumn("On date (UTC)"), - new AsciiColumn("Reason"), - new AsciiColumn("Context", disabled: !ctx.Channel.IsPrivate) + new AsciiColumn("Count", alignToRight: true), + new AsciiColumn("All time", alignToRight: true) ); await using var db = new BotDb(); - var query = from warn in db.Warning - where warn.IssuerId == moderatorId && !warn.Retracted - orderby warn.Id descending - select warn; + var query = from warn in db.Warning.AsEnumerable() + group warn by warn.DiscordId + into userGroup + let row = new {discordId = userGroup.Key, count = userGroup.Count(w => !w.Retracted), total = userGroup.Count()} + orderby row.count descending + select row; foreach (var row in query.Take(number)) { - var username = await ctx.GetUserNameAsync(row.DiscordId).ConfigureAwait(false); - var timestamp = row.Timestamp.HasValue ? new DateTime(row.Timestamp.Value, DateTimeKind.Utc).ToString("u") : ""; - table.Add(row.Id.ToString(), username, row.DiscordId.ToString(), timestamp, row.Reason, row.FullReason); + var username = await ctx.GetUserNameAsync(row.discordId).ConfigureAwait(false); + table.Add(username, row.discordId.ToString(), row.count.ToString(), row.total.ToString()); } - var modName = await ctx.GetUserNameAsync(moderatorId, defaultName: "Unknown mod").ConfigureAwait(false); - await ctx.SendAutosplitMessageAsync(new StringBuilder($"Recent warnings issued by {modName}:").Append(table)).ConfigureAwait(false); - + await ctx.SendAutosplitMessageAsync(new StringBuilder("Warning count per user:").Append(table)).ConfigureAwait(false); } - - [Command("by"), Priority(1), RequiresBotModRole] - public async Task By(CommandContext ctx, string me, [Description("Optional number of items to show. Default is 10")] int number = 10) + catch (Exception e) { - if (me.ToLowerInvariant() == "me") - { - await By(ctx, ctx.User.Id, number).ConfigureAwait(false); - return; - } - - var user = await ((IArgumentConverter)new DiscordUserConverter()).ConvertAsync(me, ctx).ConfigureAwait(false); - if (user.HasValue) - await By(ctx, user.Value, number).ConfigureAwait(false); - } - - [Command("by"), Priority(10), RequiresBotModRole] - public Task By(CommandContext ctx, DiscordUser moderator, [Description("Optional number of items to show. Default is 10")] int number = 10) - => By(ctx, moderator.Id, number); - - [Command("recent"), Aliases("last", "all"), RequiresBotModRole] - [Description("Shows last issued warnings in chronological order")] - public async Task Last(CommandContext ctx, [Description("Optional number of items to show. Default is 10")] int number = 10) - { - var isMod = ctx.User.IsWhitelisted(ctx.Client, ctx.Guild); - var showRetractions = ctx.Channel.IsPrivate && isMod; - if (number < 1) - number = 10; - var table = new AsciiTable( - new AsciiColumn("ID", alignToRight: true), - new AsciiColumn("±", disabled: !showRetractions), - new AsciiColumn("Username", maxWidth: 24), - new AsciiColumn("User ID", disabled: !ctx.Channel.IsPrivate, alignToRight: true), - new AsciiColumn("Issued by", maxWidth: 15), - new AsciiColumn("On date (UTC)"), - new AsciiColumn("Reason"), - new AsciiColumn("Context", disabled: !ctx.Channel.IsPrivate) - ); - await using var db = new BotDb(); - IOrderedQueryable query; - if (showRetractions) - query = from warn in db.Warning - orderby warn.Id descending - select warn; - else - query = from warn in db.Warning - where !warn.Retracted - orderby warn.Id descending - select warn; - foreach (var row in query.Take(number)) - { - var username = await ctx.GetUserNameAsync(row.DiscordId).ConfigureAwait(false); - var modName = await ctx.GetUserNameAsync(row.IssuerId, defaultName: "Unknown mod").ConfigureAwait(false); - var timestamp = row.Timestamp.HasValue ? new DateTime(row.Timestamp.Value, DateTimeKind.Utc).ToString("u") : ""; - if (row.Retracted) - { - var modNameRetracted = row.RetractedBy.HasValue ? await ctx.GetUserNameAsync(row.RetractedBy.Value, defaultName: "Unknown mod").ConfigureAwait(false) : ""; - var timestampRetracted = row.RetractionTimestamp.HasValue ? new DateTime(row.RetractionTimestamp.Value, DateTimeKind.Utc).ToString("u") : ""; - table.Add(row.Id.ToString(), "-", username, row.DiscordId.ToString(), modNameRetracted, timestampRetracted, row.RetractionReason ?? "", ""); - table.Add(row.Id.ToString(), "+", username.StrikeThrough(), row.DiscordId.ToString().StrikeThrough(), modName.StrikeThrough(), timestamp.StrikeThrough(), row.Reason.StrikeThrough(), row.FullReason.StrikeThrough()); - } - else - table.Add(row.Id.ToString(), "+", username, row.DiscordId.ToString(), modName, timestamp, row.Reason, row.FullReason); - } - await ctx.SendAutosplitMessageAsync(new StringBuilder("Recent warnings:").Append(table)).ConfigureAwait(false); - } - - private async Task CheckListPermissionAsync(CommandContext ctx, ulong userId) - { - if (userId == ctx.Message.Author.Id || ModProvider.IsMod(ctx.Message.Author.Id)) - return true; - - await ctx.ReactWithAsync(Config.Reactions.Denied, "Regular users can only view their own warnings").ConfigureAwait(false); - return false; + Config.Log.Error(e); + await ctx.ReactWithAsync(Config.Reactions.Failure, "SQL query for this command is broken at the moment", true).ConfigureAwait(false); } } + + [Command("mods"), Aliases("mtop"), RequiresBotModRole, TriggersTyping] + [Description("List bot mods, sorted by the number of warnings issued")] + public async Task Mods(CommandContext ctx, [Description("Optional number of items to show. Default is 10")] int number = 10) + { + try + { + if (number < 1) + number = 10; + var table = new AsciiTable( + new AsciiColumn("Username", maxWidth: 24), + new AsciiColumn("Issuer ID", disabled: !ctx.Channel.IsPrivate, alignToRight: true), + new AsciiColumn("Warnings given", alignToRight: true), + new AsciiColumn("Including retracted", alignToRight: true) + ); + await using var db = new BotDb(); + var query = from warn in db.Warning.AsEnumerable() + group warn by warn.IssuerId + into modGroup + let row = new {userId = modGroup.Key, count = modGroup.Count(w => !w.Retracted), total = modGroup.Count()} + orderby row.count descending + select row; + foreach (var row in query.Take(number)) + { + var username = await ctx.GetUserNameAsync(row.userId).ConfigureAwait(false); + if (username is null or "") + username = "Unknown"; + table.Add(username, row.userId.ToString(), row.count.ToString(), row.total.ToString()); + } + await ctx.SendAutosplitMessageAsync(new StringBuilder("Warnings issued per bot mod:").Append(table)).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e); + await ctx.ReactWithAsync(Config.Reactions.Failure, "SQL query for this command is broken at the moment", true).ConfigureAwait(false); + } + } + + [Command("by"), RequiresBotModRole] + [Description("Shows warnings issued by the specified moderator")] + public async Task By(CommandContext ctx, ulong moderatorId, [Description("Optional number of items to show. Default is 10")] int number = 10) + { + if (number < 1) + number = 10; + var table = new AsciiTable( + new AsciiColumn("ID", alignToRight: true), + new AsciiColumn("Username", maxWidth: 24), + new AsciiColumn("User ID", disabled: !ctx.Channel.IsPrivate, alignToRight: true), + new AsciiColumn("On date (UTC)"), + new AsciiColumn("Reason"), + new AsciiColumn("Context", disabled: !ctx.Channel.IsPrivate) + ); + await using var db = new BotDb(); + var query = from warn in db.Warning + where warn.IssuerId == moderatorId && !warn.Retracted + orderby warn.Id descending + select warn; + foreach (var row in query.Take(number)) + { + var username = await ctx.GetUserNameAsync(row.DiscordId).ConfigureAwait(false); + var timestamp = row.Timestamp.HasValue ? new DateTime(row.Timestamp.Value, DateTimeKind.Utc).ToString("u") : ""; + table.Add(row.Id.ToString(), username, row.DiscordId.ToString(), timestamp, row.Reason, row.FullReason); + } + var modName = await ctx.GetUserNameAsync(moderatorId, defaultName: "Unknown mod").ConfigureAwait(false); + await ctx.SendAutosplitMessageAsync(new StringBuilder($"Recent warnings issued by {modName}:").Append(table)).ConfigureAwait(false); + + } + + [Command("by"), Priority(1), RequiresBotModRole] + public async Task By(CommandContext ctx, string me, [Description("Optional number of items to show. Default is 10")] int number = 10) + { + if (me.ToLowerInvariant() == "me") + { + await By(ctx, ctx.User.Id, number).ConfigureAwait(false); + return; + } + + var user = await ((IArgumentConverter)new DiscordUserConverter()).ConvertAsync(me, ctx).ConfigureAwait(false); + if (user.HasValue) + await By(ctx, user.Value, number).ConfigureAwait(false); + } + + [Command("by"), Priority(10), RequiresBotModRole] + public Task By(CommandContext ctx, DiscordUser moderator, [Description("Optional number of items to show. Default is 10")] int number = 10) + => By(ctx, moderator.Id, number); + + [Command("recent"), Aliases("last", "all"), RequiresBotModRole] + [Description("Shows last issued warnings in chronological order")] + public async Task Last(CommandContext ctx, [Description("Optional number of items to show. Default is 10")] int number = 10) + { + var isMod = ctx.User.IsWhitelisted(ctx.Client, ctx.Guild); + var showRetractions = ctx.Channel.IsPrivate && isMod; + if (number < 1) + number = 10; + var table = new AsciiTable( + new AsciiColumn("ID", alignToRight: true), + new AsciiColumn("±", disabled: !showRetractions), + new AsciiColumn("Username", maxWidth: 24), + new AsciiColumn("User ID", disabled: !ctx.Channel.IsPrivate, alignToRight: true), + new AsciiColumn("Issued by", maxWidth: 15), + new AsciiColumn("On date (UTC)"), + new AsciiColumn("Reason"), + new AsciiColumn("Context", disabled: !ctx.Channel.IsPrivate) + ); + await using var db = new BotDb(); + IOrderedQueryable query; + if (showRetractions) + query = from warn in db.Warning + orderby warn.Id descending + select warn; + else + query = from warn in db.Warning + where !warn.Retracted + orderby warn.Id descending + select warn; + foreach (var row in query.Take(number)) + { + var username = await ctx.GetUserNameAsync(row.DiscordId).ConfigureAwait(false); + var modName = await ctx.GetUserNameAsync(row.IssuerId, defaultName: "Unknown mod").ConfigureAwait(false); + var timestamp = row.Timestamp.HasValue ? new DateTime(row.Timestamp.Value, DateTimeKind.Utc).ToString("u") : ""; + if (row.Retracted) + { + var modNameRetracted = row.RetractedBy.HasValue ? await ctx.GetUserNameAsync(row.RetractedBy.Value, defaultName: "Unknown mod").ConfigureAwait(false) : ""; + var timestampRetracted = row.RetractionTimestamp.HasValue ? new DateTime(row.RetractionTimestamp.Value, DateTimeKind.Utc).ToString("u") : ""; + table.Add(row.Id.ToString(), "-", username, row.DiscordId.ToString(), modNameRetracted, timestampRetracted, row.RetractionReason ?? "", ""); + table.Add(row.Id.ToString(), "+", username.StrikeThrough(), row.DiscordId.ToString().StrikeThrough(), modName.StrikeThrough(), timestamp.StrikeThrough(), row.Reason.StrikeThrough(), row.FullReason.StrikeThrough()); + } + else + table.Add(row.Id.ToString(), "+", username, row.DiscordId.ToString(), modName, timestamp, row.Reason, row.FullReason); + } + await ctx.SendAutosplitMessageAsync(new StringBuilder("Recent warnings:").Append(table)).ConfigureAwait(false); + } + + private async Task CheckListPermissionAsync(CommandContext ctx, ulong userId) + { + if (userId == ctx.Message.Author.Id || ModProvider.IsMod(ctx.Message.Author.Id)) + return true; + + await ctx.ReactWithAsync(Config.Reactions.Denied, "Regular users can only view their own warnings").ConfigureAwait(false); + return false; + } } -} +} \ No newline at end of file diff --git a/CompatBot/Commands/Warnings.cs b/CompatBot/Commands/Warnings.cs index a49cdf77..205bb47f 100644 --- a/CompatBot/Commands/Warnings.cs +++ b/CompatBot/Commands/Warnings.cs @@ -13,101 +13,137 @@ using DSharpPlus.Entities; using DSharpPlus.Interactivity.Extensions; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Commands +namespace CompatBot.Commands; + +[Group("warn")] +[Description("Command used to manage warnings")] +internal sealed partial class Warnings: BaseCommandModuleCustom { - [Group("warn")] - [Description("Command used to manage warnings")] - internal sealed partial class Warnings: BaseCommandModuleCustom + [GroupCommand] //attributes on overloads do not work, so no easy permission checks + [Description("Command used to issue a new warning")] + public async Task Warn(CommandContext ctx, [Description("User to warn. Can also use @id")] DiscordUser user, [RemainingText, Description("Warning explanation")] string reason) { - [GroupCommand] //attributes on overloads do not work, so no easy permission checks - [Description("Command used to issue a new warning")] - public async Task Warn(CommandContext ctx, [Description("User to warn. Can also use @id")] DiscordUser user, [RemainingText, Description("Warning explanation")] string reason) - { - //need to do manual check of the attribute in all GroupCommand overloads :( - if (!await new RequiresBotModRole().ExecuteCheckAsync(ctx, false).ConfigureAwait(false)) - return; + //need to do manual check of the attribute in all GroupCommand overloads :( + if (!await new RequiresBotModRole().ExecuteCheckAsync(ctx, false).ConfigureAwait(false)) + return; - if (await AddAsync(ctx, user.Id, user.Username.Sanitize(), ctx.Message.Author, reason).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't save the warning, please try again").ConfigureAwait(false); + if (await AddAsync(ctx, user.Id, user.Username.Sanitize(), ctx.Message.Author, reason).ConfigureAwait(false)) + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't save the warning, please try again").ConfigureAwait(false); + } + + [GroupCommand] + public async Task Warn(CommandContext ctx, [Description("ID of a user to warn")] ulong userId, [RemainingText, Description("Warning explanation")] string reason) + { + if (!await new RequiresBotModRole().ExecuteCheckAsync(ctx, false).ConfigureAwait(false)) + return; + + if (await AddAsync(ctx, userId, $"<@{userId}>", ctx.Message.Author, reason).ConfigureAwait(false)) + await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); + else + await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't save the warning, please try again").ConfigureAwait(false); + } + + [Command("edit"), RequiresBotModRole] + [Description("Edit specified warning")] + public async Task Edit(CommandContext ctx, [Description("Warning ID to edit")] int id) + { + var interact = ctx.Client.GetInteractivity(); + await using var db = new BotDb(); + var warnings = await db.Warning.Where(w => id.Equals(w.Id)).ToListAsync().ConfigureAwait(false); + + if (warnings.Count == 0) + { + await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} Warn not found", true); + return; } - [GroupCommand] - public async Task Warn(CommandContext ctx, [Description("ID of a user to warn")] ulong userId, [RemainingText, Description("Warning explanation")] string reason) - { - if (!await new RequiresBotModRole().ExecuteCheckAsync(ctx, false).ConfigureAwait(false)) - return; + var warningToEdit = warnings.First(); - if (await AddAsync(ctx, userId, $"<@{userId}>", ctx.Message.Author, reason).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't save the warning, please try again").ConfigureAwait(false); + if (warningToEdit.IssuerId != ctx.User.Id) + { + await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} This warn wasn't issued by you :(", true); + return; } - [Command("edit"), RequiresBotModRole] - [Description("Edit specified warning")] - public async Task Edit(CommandContext ctx, [Description("Warning ID to edit")] int id) + var msg = await ctx.Channel.SendMessageAsync("Updated warn reason?").ConfigureAwait(false); + var response = await interact.WaitForMessageAsync( + m => m.Author == ctx.User + && m.Channel == ctx.Channel + && !string.IsNullOrEmpty(m.Content) + ).ConfigureAwait(false); + + await msg.DeleteAsync().ConfigureAwait(false); + + if (string.IsNullOrEmpty(response.Result?.Content)) + { + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't edit warning without a new reason").ConfigureAwait(false); + return; + } + + warningToEdit.Reason = response.Result.Content; + + await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"Warning successfully edited!").ConfigureAwait(false); + } + + [Command("remove"), Aliases("delete", "del"), RequiresBotModRole] + [Description("Removes specified warnings")] + public async Task Remove(CommandContext ctx, [Description("Warning IDs to remove separated with space")] params int[] ids) + { + var interact = ctx.Client.GetInteractivity(); + var msg = await ctx.Channel.SendMessageAsync("What is the reason for removal?").ConfigureAwait(false); + var response = await interact.WaitForMessageAsync( + m => m.Author == ctx.User + && m.Channel == ctx.Channel + && !string.IsNullOrEmpty(m.Content) + ).ConfigureAwait(false); + if (string.IsNullOrEmpty(response.Result?.Content)) + { + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't remove warnings without a reason").ConfigureAwait(false); + return; + } + + await msg.DeleteAsync().ConfigureAwait(false); + await using var db = new BotDb(); + var warningsToRemove = await db.Warning.Where(w => ids.Contains(w.Id)).ToListAsync().ConfigureAwait(false); + foreach (var w in warningsToRemove) + { + w.Retracted = true; + w.RetractedBy = ctx.User.Id; + w.RetractionReason = response.Result.Content; + w.RetractionTimestamp = DateTime.UtcNow.Ticks; + } + var removedCount = await db.SaveChangesAsync().ConfigureAwait(false); + if (removedCount == ids.Length) + await ctx.Channel.SendMessageAsync($"Warning{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); + else + await ctx.Channel.SendMessageAsync($"Removed {removedCount} items, but was asked to remove {ids.Length}").ConfigureAwait(false); + } + + [Command("clear"), RequiresBotModRole] + [Description("Removes **all** warnings for a user")] + public Task Clear(CommandContext ctx, [Description("User to clear warnings for")] DiscordUser user) + => Clear(ctx, user.Id); + + [Command("clear"), RequiresBotModRole] + public async Task Clear(CommandContext ctx, [Description("User ID to clear warnings for")] ulong userId) + { + var interact = ctx.Client.GetInteractivity(); + var msg = await ctx.Channel.SendMessageAsync("What is the reason for removing all the warnings?").ConfigureAwait(false); + var response = await interact.WaitForMessageAsync(m => m.Author == ctx.User && m.Channel == ctx.Channel && !string.IsNullOrEmpty(m.Content)).ConfigureAwait(false); + if (string.IsNullOrEmpty(response.Result?.Content)) + { + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't remove warnings without a reason").ConfigureAwait(false); + return; + } + + await msg.DeleteAsync().ConfigureAwait(false); + try { - var interact = ctx.Client.GetInteractivity(); await using var db = new BotDb(); - var warnings = await db.Warning.Where(w => id.Equals(w.Id)).ToListAsync().ConfigureAwait(false); - - if (warnings.Count == 0) - { - await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} Warn not found", true); - return; - } - - var warningToEdit = warnings.First(); - - if (warningToEdit.IssuerId != ctx.User.Id) - { - await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} This warn wasn't issued by you :(", true); - return; - } - - var msg = await ctx.Channel.SendMessageAsync("Updated warn reason?").ConfigureAwait(false); - var response = await interact.WaitForMessageAsync( - m => m.Author == ctx.User - && m.Channel == ctx.Channel - && !string.IsNullOrEmpty(m.Content) - ).ConfigureAwait(false); - - await msg.DeleteAsync().ConfigureAwait(false); - - if (string.IsNullOrEmpty(response.Result?.Content)) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't edit warning without a new reason").ConfigureAwait(false); - return; - } - - warningToEdit.Reason = response.Result.Content; - - await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"Warning successfully edited!").ConfigureAwait(false); - } - - [Command("remove"), Aliases("delete", "del"), RequiresBotModRole] - [Description("Removes specified warnings")] - public async Task Remove(CommandContext ctx, [Description("Warning IDs to remove separated with space")] params int[] ids) - { - var interact = ctx.Client.GetInteractivity(); - var msg = await ctx.Channel.SendMessageAsync("What is the reason for removal?").ConfigureAwait(false); - var response = await interact.WaitForMessageAsync( - m => m.Author == ctx.User - && m.Channel == ctx.Channel - && !string.IsNullOrEmpty(m.Content) - ).ConfigureAwait(false); - if (string.IsNullOrEmpty(response.Result?.Content)) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't remove warnings without a reason").ConfigureAwait(false); - return; - } - - await msg.DeleteAsync().ConfigureAwait(false); - await using var db = new BotDb(); - var warningsToRemove = await db.Warning.Where(w => ids.Contains(w.Id)).ToListAsync().ConfigureAwait(false); + var warningsToRemove = await db.Warning.Where(w => w.DiscordId == userId && !w.Retracted).ToListAsync().ConfigureAwait(false); foreach (var w in warningsToRemove) { w.Retracted = true; @@ -115,230 +151,193 @@ namespace CompatBot.Commands w.RetractionReason = response.Result.Content; w.RetractionTimestamp = DateTime.UtcNow.Ticks; } - var removedCount = await db.SaveChangesAsync().ConfigureAwait(false); - if (removedCount == ids.Length) - await ctx.Channel.SendMessageAsync($"Warning{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); - else - await ctx.Channel.SendMessageAsync($"Removed {removedCount} items, but was asked to remove {ids.Length}").ConfigureAwait(false); + var removed = await db.SaveChangesAsync().ConfigureAwait(false); + await ctx.Channel.SendMessageAsync($"{removed} warning{StringUtils.GetSuffix(removed)} successfully removed!").ConfigureAwait(false); } - - [Command("clear"), RequiresBotModRole] - [Description("Removes **all** warnings for a user")] - public Task Clear(CommandContext ctx, [Description("User to clear warnings for")] DiscordUser user) - => Clear(ctx, user.Id); - - [Command("clear"), RequiresBotModRole] - public async Task Clear(CommandContext ctx, [Description("User ID to clear warnings for")] ulong userId) + catch (Exception e) { - var interact = ctx.Client.GetInteractivity(); - var msg = await ctx.Channel.SendMessageAsync("What is the reason for removing all the warnings?").ConfigureAwait(false); - var response = await interact.WaitForMessageAsync(m => m.Author == ctx.User && m.Channel == ctx.Channel && !string.IsNullOrEmpty(m.Content)).ConfigureAwait(false); - if (string.IsNullOrEmpty(response.Result?.Content)) + Config.Log.Error(e); + } + } + + [Command("revert"), RequiresBotModRole] + [Description("Changes the state of the warning status")] + public async Task Revert(CommandContext ctx, [Description("Warning ID to change")] int id) + { + await using var db = new BotDb(); + var warn = await db.Warning.FirstOrDefaultAsync(w => w.Id == id).ConfigureAwait(false); + if (warn?.Retracted is true) + { + warn.Retracted = false; + warn.RetractedBy = null; + warn.RetractionReason = null; + warn.RetractionTimestamp = null; + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + await ctx.ReactWithAsync(Config.Reactions.Success, "Reissued the warning", true).ConfigureAwait(false); + } + else + await Remove(ctx, id).ConfigureAwait(false); + } + + internal static async Task AddAsync(CommandContext ctx, ulong userId, string userName, DiscordUser issuer, string? reason, string? fullReason = null) + { + reason = await Sudo.Fix.FixChannelMentionAsync(ctx, reason).ConfigureAwait(false); + return await AddAsync(ctx.Client, ctx.Message, userId, userName, issuer, reason, fullReason); + } + + internal static async Task AddAsync(DiscordClient client, DiscordMessage message, ulong userId, string userName, DiscordUser issuer, string? reason, string? fullReason = null) + { + if (string.IsNullOrEmpty(reason)) + { + var interact = client.GetInteractivity(); + var msg = await message.Channel.SendMessageAsync("What is the reason for this warning?").ConfigureAwait(false); + var response = await interact.WaitForMessageAsync( + m => m.Author == message.Author + && m.Channel == message.Channel + && !string.IsNullOrEmpty(m.Content) + ).ConfigureAwait(false); + if (string.IsNullOrEmpty(response.Result.Content)) { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't remove warnings without a reason").ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(message.Channel, "A reason needs to be provided").ConfigureAwait(false); + return false; + } + + await msg.DeleteAsync().ConfigureAwait(false); + reason = response.Result.Content; + } + try + { + await using var db = new BotDb(); + await db.Warning.AddAsync(new Warning { DiscordId = userId, IssuerId = issuer.Id, Reason = reason, FullReason = fullReason ?? "", Timestamp = DateTime.UtcNow.Ticks }).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + + var threshold = DateTime.UtcNow.AddMinutes(-15).Ticks; + var recentCount = db.Warning.Count(w => w.DiscordId == userId && !w.Retracted && w.Timestamp > threshold); + if (recentCount > 3) + { + Config.Log.Debug("Suicide behavior detected, not spamming with warning responses"); + return true; + } + + var totalCount = db.Warning.Count(w => w.DiscordId == userId && !w.Retracted); + await message.Channel.SendMessageAsync($"User warning saved! User currently has {totalCount} warning{StringUtils.GetSuffix(totalCount)}!").ConfigureAwait(false); + if (totalCount > 1) + await ListUserWarningsAsync(client, message, userId, userName).ConfigureAwait(false); + return true; + } + catch (Exception e) + { + Config.Log.Error(e, "Couldn't save the warning"); + return false; + } + } + + //note: be sure to pass a sanitized userName + private static async Task ListUserWarningsAsync(DiscordClient client, DiscordMessage message, ulong userId, string userName, bool skipIfOne = true) + { + try + { + var isWhitelisted = client.GetMember(message.Author)?.IsWhitelisted() is true; + if (message.Author.Id != userId && !isWhitelisted) + { + Config.Log.Error($"Somehow {message.Author.Username} ({message.Author.Id}) triggered warning list for {userId}"); return; } - await msg.DeleteAsync().ConfigureAwait(false); - try - { - await using var db = new BotDb(); - var warningsToRemove = await db.Warning.Where(w => w.DiscordId == userId && !w.Retracted).ToListAsync().ConfigureAwait(false); - foreach (var w in warningsToRemove) - { - w.Retracted = true; - w.RetractedBy = ctx.User.Id; - w.RetractionReason = response.Result.Content; - w.RetractionTimestamp = DateTime.UtcNow.Ticks; - } - var removed = await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"{removed} warning{StringUtils.GetSuffix(removed)} successfully removed!").ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - } - } - - [Command("revert"), RequiresBotModRole] - [Description("Changes the state of the warning status")] - public async Task Revert(CommandContext ctx, [Description("Warning ID to change")] int id) - { + var channel = message.Channel; + var isPrivate = channel.IsPrivate; + int count, removed; + bool isKot, isDoggo; await using var db = new BotDb(); - var warn = await db.Warning.FirstOrDefaultAsync(w => w.Id == id).ConfigureAwait(false); - if (warn?.Retracted is true) + count = await db.Warning.CountAsync(w => w.DiscordId == userId && !w.Retracted).ConfigureAwait(false); + removed = await db.Warning.CountAsync(w => w.DiscordId == userId && w.Retracted).ConfigureAwait(false); + isKot = db.Kot.Any(k => k.UserId == userId); + isDoggo = db.Doggo.Any(d => d.UserId == userId); + if (count == 0) { - warn.Retracted = false; - warn.RetractedBy = null; - warn.RetractionReason = null; - warn.RetractionTimestamp = null; - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, "Reissued the warning", true).ConfigureAwait(false); - } - else - await Remove(ctx, id).ConfigureAwait(false); - } - - internal static async Task AddAsync(CommandContext ctx, ulong userId, string userName, DiscordUser issuer, string? reason, string? fullReason = null) - { - reason = await Sudo.Fix.FixChannelMentionAsync(ctx, reason).ConfigureAwait(false); - return await AddAsync(ctx.Client, ctx.Message, userId, userName, issuer, reason, fullReason); - } - - internal static async Task AddAsync(DiscordClient client, DiscordMessage message, ulong userId, string userName, DiscordUser issuer, string? reason, string? fullReason = null) - { - if (string.IsNullOrEmpty(reason)) - { - var interact = client.GetInteractivity(); - var msg = await message.Channel.SendMessageAsync("What is the reason for this warning?").ConfigureAwait(false); - var response = await interact.WaitForMessageAsync( - m => m.Author == message.Author - && m.Channel == message.Channel - && !string.IsNullOrEmpty(m.Content) - ).ConfigureAwait(false); - if (string.IsNullOrEmpty(response.Result.Content)) + if (isKot && isDoggo) { - await msg.UpdateOrCreateMessageAsync(message.Channel, "A reason needs to be provided").ConfigureAwait(false); - return false; - } - - await msg.DeleteAsync().ConfigureAwait(false); - reason = response.Result.Content; - } - try - { - await using var db = new BotDb(); - await db.Warning.AddAsync(new Warning { DiscordId = userId, IssuerId = issuer.Id, Reason = reason, FullReason = fullReason ?? "", Timestamp = DateTime.UtcNow.Ticks }).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - - var threshold = DateTime.UtcNow.AddMinutes(-15).Ticks; - var recentCount = db.Warning.Count(w => w.DiscordId == userId && !w.Retracted && w.Timestamp > threshold); - if (recentCount > 3) - { - Config.Log.Debug("Suicide behavior detected, not spamming with warning responses"); - return true; - } - - var totalCount = db.Warning.Count(w => w.DiscordId == userId && !w.Retracted); - await message.Channel.SendMessageAsync($"User warning saved! User currently has {totalCount} warning{StringUtils.GetSuffix(totalCount)}!").ConfigureAwait(false); - if (totalCount > 1) - await ListUserWarningsAsync(client, message, userId, userName).ConfigureAwait(false); - return true; - } - catch (Exception e) - { - Config.Log.Error(e, "Couldn't save the warning"); - return false; - } - } - - //note: be sure to pass a sanitized userName - private static async Task ListUserWarningsAsync(DiscordClient client, DiscordMessage message, ulong userId, string userName, bool skipIfOne = true) - { - try - { - var isWhitelisted = client.GetMember(message.Author)?.IsWhitelisted() is true; - if (message.Author.Id != userId && !isWhitelisted) - { - Config.Log.Error($"Somehow {message.Author.Username} ({message.Author.Id}) triggered warning list for {userId}"); - return; - } - - var channel = message.Channel; - var isPrivate = channel.IsPrivate; - int count, removed; - bool isKot, isDoggo; - await using var db = new BotDb(); - count = await db.Warning.CountAsync(w => w.DiscordId == userId && !w.Retracted).ConfigureAwait(false); - removed = await db.Warning.CountAsync(w => w.DiscordId == userId && w.Retracted).ConfigureAwait(false); - isKot = db.Kot.Any(k => k.UserId == userId); - isDoggo = db.Doggo.Any(d => d.UserId == userId); - if (count == 0) - { - if (isKot && isDoggo) - { - if (new Random().NextDouble() < 0.5) - isKot = false; - else - isDoggo = false; - } - var msg = (removed, isPrivate, isKot, isDoggo) switch - { - (0, _, true, false) => $"{userName} has no warnings, is an upstanding kot, and a paw bean of this community", - (0, _, false, true) => $"{userName} has no warnings, is a good boy, and a wiggling tail of this community", - (0, _, _, _) => $"{userName} has no warnings, is an upstanding citizen, and a pillar of this community", - (_, true, _, _) => $"{userName} has no warnings ({removed} retracted warning{(removed == 1 ? "" : "s")})", - (_, _, true, false) => $"{userName} has no warnings, but are they a good kot?", - (_, _, false, true) => $"{userName} has no warnings, but are they a good boy?", - _ => $"{userName} has no warnings", - }; - await message.Channel.SendMessageAsync(msg).ConfigureAwait(false); - if (!isPrivate || removed == 0) - return; - } - - if (count == 1 && skipIfOne) - return; - - const int maxWarningsInPublicChannel = 3; - var showCount = Math.Min(maxWarningsInPublicChannel, count); - var table = new AsciiTable( - new AsciiColumn("ID", alignToRight: true), - new AsciiColumn("±", disabled: !isPrivate || !isWhitelisted), - new AsciiColumn("By", maxWidth: 15), - new AsciiColumn("On date (UTC)"), - new AsciiColumn("Reason"), - new AsciiColumn("Context", disabled: !isPrivate, maxWidth: 4096) - ); - IQueryable query = db.Warning.Where(w => w.DiscordId == userId).OrderByDescending(w => w.Id); - if (!isPrivate || !isWhitelisted) - query = query.Where(w => !w.Retracted); - if (!isPrivate && !isWhitelisted) - query = query.Take(maxWarningsInPublicChannel); - foreach (var warning in await query.ToListAsync().ConfigureAwait(false)) - { - if (warning.Retracted) - { - if (isWhitelisted && isPrivate) - { - var retractedByName = warning.RetractedBy.HasValue - ? await client.GetUserNameAsync(channel, warning.RetractedBy.Value, isPrivate, "unknown mod").ConfigureAwait(false) - : ""; - var retractionTimestamp = warning.RetractionTimestamp.HasValue - ? new DateTime(warning.RetractionTimestamp.Value, DateTimeKind.Utc).ToString("u") - : ""; - table.Add(warning.Id.ToString(), "-", retractedByName, retractionTimestamp, warning.RetractionReason ?? "", ""); - - var issuerName = warning.IssuerId == 0 - ? "" - : await client.GetUserNameAsync(channel, warning.IssuerId, isPrivate, "unknown mod").ConfigureAwait(false); - var timestamp = warning.Timestamp.HasValue - ? new DateTime(warning.Timestamp.Value, DateTimeKind.Utc).ToString("u") - : ""; - table.Add(warning.Id.ToString().StrikeThrough(), "+", issuerName.StrikeThrough(), timestamp.StrikeThrough(), warning.Reason.StrikeThrough(), warning.FullReason.StrikeThrough()); - } - } + if (new Random().NextDouble() < 0.5) + isKot = false; else + isDoggo = false; + } + var msg = (removed, isPrivate, isKot, isDoggo) switch + { + (0, _, true, false) => $"{userName} has no warnings, is an upstanding kot, and a paw bean of this community", + (0, _, false, true) => $"{userName} has no warnings, is a good boy, and a wiggling tail of this community", + (0, _, _, _) => $"{userName} has no warnings, is an upstanding citizen, and a pillar of this community", + (_, true, _, _) => $"{userName} has no warnings ({removed} retracted warning{(removed == 1 ? "" : "s")})", + (_, _, true, false) => $"{userName} has no warnings, but are they a good kot?", + (_, _, false, true) => $"{userName} has no warnings, but are they a good boy?", + _ => $"{userName} has no warnings", + }; + await message.Channel.SendMessageAsync(msg).ConfigureAwait(false); + if (!isPrivate || removed == 0) + return; + } + + if (count == 1 && skipIfOne) + return; + + const int maxWarningsInPublicChannel = 3; + var showCount = Math.Min(maxWarningsInPublicChannel, count); + var table = new AsciiTable( + new AsciiColumn("ID", alignToRight: true), + new AsciiColumn("±", disabled: !isPrivate || !isWhitelisted), + new AsciiColumn("By", maxWidth: 15), + new AsciiColumn("On date (UTC)"), + new AsciiColumn("Reason"), + new AsciiColumn("Context", disabled: !isPrivate, maxWidth: 4096) + ); + IQueryable query = db.Warning.Where(w => w.DiscordId == userId).OrderByDescending(w => w.Id); + if (!isPrivate || !isWhitelisted) + query = query.Where(w => !w.Retracted); + if (!isPrivate && !isWhitelisted) + query = query.Take(maxWarningsInPublicChannel); + foreach (var warning in await query.ToListAsync().ConfigureAwait(false)) + { + if (warning.Retracted) + { + if (isWhitelisted && isPrivate) { + var retractedByName = warning.RetractedBy.HasValue + ? await client.GetUserNameAsync(channel, warning.RetractedBy.Value, isPrivate, "unknown mod").ConfigureAwait(false) + : ""; + var retractionTimestamp = warning.RetractionTimestamp.HasValue + ? new DateTime(warning.RetractionTimestamp.Value, DateTimeKind.Utc).ToString("u") + : ""; + table.Add(warning.Id.ToString(), "-", retractedByName, retractionTimestamp, warning.RetractionReason ?? "", ""); + var issuerName = warning.IssuerId == 0 ? "" : await client.GetUserNameAsync(channel, warning.IssuerId, isPrivate, "unknown mod").ConfigureAwait(false); var timestamp = warning.Timestamp.HasValue ? new DateTime(warning.Timestamp.Value, DateTimeKind.Utc).ToString("u") : ""; - table.Add(warning.Id.ToString(), "+", issuerName, timestamp, warning.Reason, warning.FullReason); + table.Add(warning.Id.ToString().StrikeThrough(), "+", issuerName.StrikeThrough(), timestamp.StrikeThrough(), warning.Reason.StrikeThrough(), warning.FullReason.StrikeThrough()); } } - var result = new StringBuilder("Warning list for ").Append(userName); - if (!isPrivate && !isWhitelisted && count > maxWarningsInPublicChannel) - result.Append($" (last {showCount} of {count}, full list in DMs)"); - result.AppendLine(":").Append(table); - await channel.SendAutosplitMessageAsync(result).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e); + else + { + var issuerName = warning.IssuerId == 0 + ? "" + : await client.GetUserNameAsync(channel, warning.IssuerId, isPrivate, "unknown mod").ConfigureAwait(false); + var timestamp = warning.Timestamp.HasValue + ? new DateTime(warning.Timestamp.Value, DateTimeKind.Utc).ToString("u") + : ""; + table.Add(warning.Id.ToString(), "+", issuerName, timestamp, warning.Reason, warning.FullReason); + } } + var result = new StringBuilder("Warning list for ").Append(userName); + if (!isPrivate && !isWhitelisted && count > maxWarningsInPublicChannel) + result.Append($" (last {showCount} of {count}, full list in DMs)"); + result.AppendLine(":").Append(table); + await channel.SendAutosplitMessageAsync(result).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e); } } -} +} \ No newline at end of file diff --git a/CompatBot/Config.cs b/CompatBot/Config.cs index bebb74ab..4ee0b62b 100644 --- a/CompatBot/Config.cs +++ b/CompatBot/Config.cs @@ -26,297 +26,296 @@ using NLog.Targets.Wrappers; using ILogger = NLog.ILogger; using LogLevel = NLog.LogLevel; -namespace CompatBot +namespace CompatBot; + +internal static class Config { - internal static class Config + private static IConfigurationRoot config = null!; + private static TelemetryClient? telemetryClient; + private static readonly DependencyTrackingTelemetryModule DependencyTrackingTelemetryModule = new(); + private static readonly PerformanceCollectorModule PerformanceCollectorModule = new(); + + internal static readonly ILogger Log; + internal static readonly ILoggerFactory LoggerFactory; + internal static readonly ConcurrentDictionary InMemorySettings = new(); + internal static readonly RecyclableMemoryStreamManager MemoryStreamManager = new(); + + public static readonly CancellationTokenSource Cts = new(); + public static readonly Stopwatch Uptime = Stopwatch.StartNew(); + + // these settings could be configured either through `$ dotnet user-secrets`, or through environment variables (e.g. launchSettings.json, etc) + public static string CommandPrefix => config.GetValue(nameof(CommandPrefix), "!"); + public static string AutoRemoveCommandPrefix => config.GetValue(nameof(AutoRemoveCommandPrefix), "."); + public static ulong BotGuildId => config.GetValue(nameof(BotGuildId), 272035812277878785ul); // discord server where the bot is supposed to be + public static ulong BotGeneralChannelId => config.GetValue(nameof(BotGeneralChannelId), 272035812277878785ul);// #rpcs3; main or general channel where noobs come first thing + public static ulong BotChannelId => config.GetValue(nameof(BotChannelId), 291679908067803136ul); // #build-updates; this is used for new build announcements + public static ulong BotSpamId => config.GetValue(nameof(BotSpamId), 319224795785068545ul); // #bot-spam; this is a dedicated channel for bot abuse + public static ulong BotLogId => config.GetValue(nameof(BotLogId), 436972161572536329ul); // #bot-log; a private channel for admin mod queue + public static ulong BotRulesChannelId => config.GetValue(nameof(BotRulesChannelId), 311894275015049216ul); // #rules-info; used to give links to rules + public static ulong ThumbnailSpamId => config.GetValue(nameof(ThumbnailSpamId), 475678410098606100ul); // #bot-data; used for whatever bot needs to keep (cover embeds, etc) + public static ulong DeletedMessagesLogChannelId => config.GetValue(nameof(DeletedMessagesLogChannelId), 0ul); + + public static TimeSpan ModerationBacklogThresholdInHours => TimeSpan.FromHours(config.GetValue(nameof(ModerationBacklogThresholdInHours), 1)); + public static TimeSpan DefaultTimeoutInSec => TimeSpan.FromSeconds(config.GetValue(nameof(DefaultTimeoutInSec), 30)); + public static TimeSpan SocketDisconnectCheckIntervalInSec => TimeSpan.FromSeconds(config.GetValue(nameof(SocketDisconnectCheckIntervalInSec), 10)); + public static TimeSpan LogParsingTimeoutInSec => TimeSpan.FromSeconds(config.GetValue(nameof(LogParsingTimeoutInSec), 30)); + public static TimeSpan BuildTimeDifferenceForOutdatedBuildsInDays => TimeSpan.FromDays(config.GetValue(nameof(BuildTimeDifferenceForOutdatedBuildsInDays), 3)); + public static TimeSpan ShutupTimeLimitInMin => TimeSpan.FromMinutes(config.GetValue(nameof(ShutupTimeLimitInMin), 5)); + public static TimeSpan ForcedNicknamesRecheckTimeInHours => TimeSpan.FromHours(config.GetValue(nameof(ForcedNicknamesRecheckTimeInHours), 3)); + public static TimeSpan IncomingMessageCheckIntervalInMin => TimeSpan.FromMinutes(config.GetValue(nameof(IncomingMessageCheckIntervalInMin), 10)); + public static TimeSpan MetricsIntervalInSec => TimeSpan.FromSeconds(config.GetValue(nameof(MetricsIntervalInSec), 10)); + + public static int ProductCodeLookupHistoryThrottle => config.GetValue(nameof(ProductCodeLookupHistoryThrottle), 7); + public static int TopLimit => config.GetValue(nameof(TopLimit), 15); + public static int AttachmentSizeLimit => config.GetValue(nameof(AttachmentSizeLimit), 8 * 1024 * 1024); + public static int LogSizeLimit => config.GetValue(nameof(LogSizeLimit), 64 * 1024 * 1024); + public static int MinimumBufferSize => config.GetValue(nameof(MinimumBufferSize), 512); + public static int MessageCacheSize => config.GetValue(nameof(MessageCacheSize), 1024); + public static int BuildNumberDifferenceForOutdatedBuilds => config.GetValue(nameof(BuildNumberDifferenceForOutdatedBuilds), 10); + public static int MinimumPiracyTriggerLength => config.GetValue(nameof(MinimumPiracyTriggerLength), 4); + public static int MaxSyscallResultLines => config.GetValue(nameof(MaxSyscallResultLines), 13); + public static int ChannelMessageHistorySize => config.GetValue(nameof(ChannelMessageHistorySize), 100); + public static int FunMultiplier => config.GetValue(nameof(FunMultiplier), 1); + public static string Token => config.GetValue(nameof(Token), ""); + public static string AzureDevOpsToken => config.GetValue(nameof(AzureDevOpsToken), ""); + public static string AzureComputerVisionKey => config.GetValue(nameof(AzureComputerVisionKey), ""); + public static string AzureComputerVisionEndpoint => config.GetValue(nameof(AzureComputerVisionEndpoint), "https://westeurope.api.cognitive.microsoft.com/"); + public static Guid AzureDevOpsProjectId => config.GetValue(nameof(AzureDevOpsProjectId), new Guid("3598951b-4d39-4fad-ad3b-ff2386a649de")); + public static string AzureAppInsightsKey => config.GetValue(nameof(AzureAppInsightsKey), ""); + public static string GithubToken => config.GetValue(nameof(GithubToken), ""); + public static string PreferredFontFamily => config.GetValue(nameof(PreferredFontFamily), ""); + public static string LogPath => config.GetValue(nameof(LogPath), "./logs/"); // paths are relative to the working directory + public static string IrdCachePath => config.GetValue(nameof(IrdCachePath), "./ird/"); + public static double GameTitleMatchThreshold => config.GetValue(nameof(GameTitleMatchThreshold), 0.57); + public static byte[] CryptoSalt => Convert.FromBase64String(config.GetValue(nameof(CryptoSalt), "")); + public static string RenameNameSuffix => config.GetValue(nameof(RenameNameSuffix), " (Rule 7)"); + + internal static class AllowedMentions { - private static IConfigurationRoot config = null!; - private static TelemetryClient? telemetryClient; - private static readonly DependencyTrackingTelemetryModule DependencyTrackingTelemetryModule = new(); - private static readonly PerformanceCollectorModule PerformanceCollectorModule = new(); + internal static readonly IMention[] UsersOnly = { UserMention.All }; + internal static readonly IMention[] Nothing = Array.Empty(); + } - internal static readonly ILogger Log; - internal static readonly ILoggerFactory LoggerFactory; - internal static readonly ConcurrentDictionary InMemorySettings = new(); - internal static readonly RecyclableMemoryStreamManager MemoryStreamManager = new(); + internal static string CurrentLogPath => Path.GetFullPath(Path.Combine(LogPath, "bot.log")); - public static readonly CancellationTokenSource Cts = new(); - public static readonly Stopwatch Uptime = Stopwatch.StartNew(); - - // these settings could be configured either through `$ dotnet user-secrets`, or through environment variables (e.g. launchSettings.json, etc) - public static string CommandPrefix => config.GetValue(nameof(CommandPrefix), "!"); - public static string AutoRemoveCommandPrefix => config.GetValue(nameof(AutoRemoveCommandPrefix), "."); - public static ulong BotGuildId => config.GetValue(nameof(BotGuildId), 272035812277878785ul); // discord server where the bot is supposed to be - public static ulong BotGeneralChannelId => config.GetValue(nameof(BotGeneralChannelId), 272035812277878785ul);// #rpcs3; main or general channel where noobs come first thing - public static ulong BotChannelId => config.GetValue(nameof(BotChannelId), 291679908067803136ul); // #build-updates; this is used for new build announcements - public static ulong BotSpamId => config.GetValue(nameof(BotSpamId), 319224795785068545ul); // #bot-spam; this is a dedicated channel for bot abuse - public static ulong BotLogId => config.GetValue(nameof(BotLogId), 436972161572536329ul); // #bot-log; a private channel for admin mod queue - public static ulong BotRulesChannelId => config.GetValue(nameof(BotRulesChannelId), 311894275015049216ul); // #rules-info; used to give links to rules - public static ulong ThumbnailSpamId => config.GetValue(nameof(ThumbnailSpamId), 475678410098606100ul); // #bot-data; used for whatever bot needs to keep (cover embeds, etc) - public static ulong DeletedMessagesLogChannelId => config.GetValue(nameof(DeletedMessagesLogChannelId), 0ul); - - public static TimeSpan ModerationBacklogThresholdInHours => TimeSpan.FromHours(config.GetValue(nameof(ModerationBacklogThresholdInHours), 1)); - public static TimeSpan DefaultTimeoutInSec => TimeSpan.FromSeconds(config.GetValue(nameof(DefaultTimeoutInSec), 30)); - public static TimeSpan SocketDisconnectCheckIntervalInSec => TimeSpan.FromSeconds(config.GetValue(nameof(SocketDisconnectCheckIntervalInSec), 10)); - public static TimeSpan LogParsingTimeoutInSec => TimeSpan.FromSeconds(config.GetValue(nameof(LogParsingTimeoutInSec), 30)); - public static TimeSpan BuildTimeDifferenceForOutdatedBuildsInDays => TimeSpan.FromDays(config.GetValue(nameof(BuildTimeDifferenceForOutdatedBuildsInDays), 3)); - public static TimeSpan ShutupTimeLimitInMin => TimeSpan.FromMinutes(config.GetValue(nameof(ShutupTimeLimitInMin), 5)); - public static TimeSpan ForcedNicknamesRecheckTimeInHours => TimeSpan.FromHours(config.GetValue(nameof(ForcedNicknamesRecheckTimeInHours), 3)); - public static TimeSpan IncomingMessageCheckIntervalInMin => TimeSpan.FromMinutes(config.GetValue(nameof(IncomingMessageCheckIntervalInMin), 10)); - public static TimeSpan MetricsIntervalInSec => TimeSpan.FromSeconds(config.GetValue(nameof(MetricsIntervalInSec), 10)); - - public static int ProductCodeLookupHistoryThrottle => config.GetValue(nameof(ProductCodeLookupHistoryThrottle), 7); - public static int TopLimit => config.GetValue(nameof(TopLimit), 15); - public static int AttachmentSizeLimit => config.GetValue(nameof(AttachmentSizeLimit), 8 * 1024 * 1024); - public static int LogSizeLimit => config.GetValue(nameof(LogSizeLimit), 64 * 1024 * 1024); - public static int MinimumBufferSize => config.GetValue(nameof(MinimumBufferSize), 512); - public static int MessageCacheSize => config.GetValue(nameof(MessageCacheSize), 1024); - public static int BuildNumberDifferenceForOutdatedBuilds => config.GetValue(nameof(BuildNumberDifferenceForOutdatedBuilds), 10); - public static int MinimumPiracyTriggerLength => config.GetValue(nameof(MinimumPiracyTriggerLength), 4); - public static int MaxSyscallResultLines => config.GetValue(nameof(MaxSyscallResultLines), 13); - public static int ChannelMessageHistorySize => config.GetValue(nameof(ChannelMessageHistorySize), 100); - public static int FunMultiplier => config.GetValue(nameof(FunMultiplier), 1); - public static string Token => config.GetValue(nameof(Token), ""); - public static string AzureDevOpsToken => config.GetValue(nameof(AzureDevOpsToken), ""); - public static string AzureComputerVisionKey => config.GetValue(nameof(AzureComputerVisionKey), ""); - public static string AzureComputerVisionEndpoint => config.GetValue(nameof(AzureComputerVisionEndpoint), "https://westeurope.api.cognitive.microsoft.com/"); - public static Guid AzureDevOpsProjectId => config.GetValue(nameof(AzureDevOpsProjectId), new Guid("3598951b-4d39-4fad-ad3b-ff2386a649de")); - public static string AzureAppInsightsKey => config.GetValue(nameof(AzureAppInsightsKey), ""); - public static string GithubToken => config.GetValue(nameof(GithubToken), ""); - public static string PreferredFontFamily => config.GetValue(nameof(PreferredFontFamily), ""); - public static string LogPath => config.GetValue(nameof(LogPath), "./logs/"); // paths are relative to the working directory - public static string IrdCachePath => config.GetValue(nameof(IrdCachePath), "./ird/"); - public static double GameTitleMatchThreshold => config.GetValue(nameof(GameTitleMatchThreshold), 0.57); - public static byte[] CryptoSalt => Convert.FromBase64String(config.GetValue(nameof(CryptoSalt), "")); - public static string RenameNameSuffix => config.GetValue(nameof(RenameNameSuffix), " (Rule 7)"); - - internal static class AllowedMentions + public static string GoogleApiConfigPath + { + get { - internal static readonly IMention[] UsersOnly = { UserMention.All }; - internal static readonly IMention[] Nothing = Array.Empty(); - } + if (SandboxDetector.Detect() == SandboxType.Docker) + return "/bot-config/credentials.json"; - internal static string CurrentLogPath => Path.GetFullPath(Path.Combine(LogPath, "bot.log")); - - public static string GoogleApiConfigPath - { - get + if (Assembly.GetEntryAssembly()?.GetCustomAttribute() is UserSecretsIdAttribute attribute + && Path.GetDirectoryName(PathHelper.GetSecretsPathFromSecretsId(attribute.UserSecretsId)) is string path) { - if (SandboxDetector.Detect() == SandboxType.Docker) - return "/bot-config/credentials.json"; - - if (Assembly.GetEntryAssembly()?.GetCustomAttribute() is UserSecretsIdAttribute attribute - && Path.GetDirectoryName(PathHelper.GetSecretsPathFromSecretsId(attribute.UserSecretsId)) is string path) - { - path = Path.Combine(path, "credentials.json"); - if (File.Exists(path)) - return path; - } + path = Path.Combine(path, "credentials.json"); + if (File.Exists(path)) + return path; + } - return "Properties/credentials.json"; - } + return "Properties/credentials.json"; } + } - public static class Colors + public static class Colors + { + public static readonly DiscordColor Help = DiscordColor.Azure; + public static readonly DiscordColor DownloadLinks = new(0x3b88c3); + public static readonly DiscordColor Maintenance = new(0xffff00); + + public static readonly DiscordColor CompatStatusNothing = new(0x455556); // colors mimic compat list statuses + public static readonly DiscordColor CompatStatusLoadable = new(0xe74c3c); + public static readonly DiscordColor CompatStatusIntro = new(0xe08a1e); + public static readonly DiscordColor CompatStatusIngame = new(0xf9b32f); + public static readonly DiscordColor CompatStatusPlayable = new(0x1ebc61); + public static readonly DiscordColor CompatStatusUnknown = new(0x3198ff); + + public static readonly DiscordColor LogResultFailed = DiscordColor.Gray; + + public static readonly DiscordColor LogAlert = new(0xf04747); // colors mimic discord statuses + public static readonly DiscordColor LogNotice = new(0xfaa61a); + public static readonly DiscordColor LogInfo = new(0x43b581); + public static readonly DiscordColor LogUnknown = new(0x747f8d); + + public static readonly DiscordColor PrOpen = new(0x2cbe4e); + public static readonly DiscordColor PrMerged = new(0x6f42c1); + public static readonly DiscordColor PrClosed = new(0xcb2431); + + public static readonly DiscordColor UpdateStatusGood = new(0x3b88c3); + public static readonly DiscordColor UpdateStatusBad = DiscordColor.Yellow; + } + + public static class Reactions + { + public static readonly DiscordEmoji Success = DiscordEmoji.FromUnicode("👌"); + public static readonly DiscordEmoji Failure = DiscordEmoji.FromUnicode("⛔"); + public static readonly DiscordEmoji Denied = DiscordEmoji.FromUnicode("👮"); + public static readonly DiscordEmoji Starbucks = DiscordEmoji.FromUnicode("☕"); + public static readonly DiscordEmoji Moderated = DiscordEmoji.FromUnicode("🔨"); + public static readonly DiscordEmoji No = DiscordEmoji.FromUnicode("😐"); + public static readonly DiscordEmoji PleaseWait = DiscordEmoji.FromUnicode("👀"); + public static readonly DiscordEmoji PiracyCheck = DiscordEmoji.FromUnicode("🔨"); + public static readonly DiscordEmoji Shutup = DiscordEmoji.FromUnicode("🔇"); + public static readonly DiscordEmoji BadUpdate = DiscordEmoji.FromUnicode("⚠\ufe0f"); + } + + public static class Moderation + { + public static readonly int StarbucksThreshold = 5; + + public static readonly IReadOnlyList Channels = new List { - public static readonly DiscordColor Help = DiscordColor.Azure; - public static readonly DiscordColor DownloadLinks = new(0x3b88c3); - public static readonly DiscordColor Maintenance = new(0xffff00); + 272875751773306881, // #media + 319224795785068545, + }.AsReadOnly(); - public static readonly DiscordColor CompatStatusNothing = new(0x455556); // colors mimic compat list statuses - public static readonly DiscordColor CompatStatusLoadable = new(0xe74c3c); - public static readonly DiscordColor CompatStatusIntro = new(0xe08a1e); - public static readonly DiscordColor CompatStatusIngame = new(0xf9b32f); - public static readonly DiscordColor CompatStatusPlayable = new(0x1ebc61); - public static readonly DiscordColor CompatStatusUnknown = new(0x3198ff); + public static readonly IReadOnlyCollection OcrChannels = new HashSet(Channels) + { + 272035812277878785, // #rpcs3 + 277227681836302338, // #help + 272875751773306881, // #media + // test server + 564846659109126244, // #media + 534749301797158914, // private-spam + }; - public static readonly DiscordColor LogResultFailed = DiscordColor.Gray; + public static readonly IReadOnlyCollection RoleWhiteList = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + "Administrator", + "Community Manager", + "Web Developer", + "Moderator", + "Lead Graphics Developer", + "Lead Core Developer", + "Developers", + "Affiliated", + }; - public static readonly DiscordColor LogAlert = new(0xf04747); // colors mimic discord statuses - public static readonly DiscordColor LogNotice = new(0xfaa61a); - public static readonly DiscordColor LogInfo = new(0x43b581); - public static readonly DiscordColor LogUnknown = new(0x747f8d); + public static readonly IReadOnlyCollection RoleSmartList = new HashSet(RoleWhiteList, StringComparer.InvariantCultureIgnoreCase) + { + "Testers", + "Helpers", + "Contributors", + }; - public static readonly DiscordColor PrOpen = new(0x2cbe4e); - public static readonly DiscordColor PrMerged = new(0x6f42c1); - public static readonly DiscordColor PrClosed = new(0xcb2431); + public static readonly IReadOnlyCollection SupporterRoleList = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + "Fans", + "Supporters", + "Spectators", + "Nitro Booster", + }; + } - public static readonly DiscordColor UpdateStatusGood = new(0x3b88c3); - public static readonly DiscordColor UpdateStatusBad = DiscordColor.Yellow; + static Config() + { + try + { + RebuildConfiguration(); + Log = GetLog(); + LoggerFactory = new NLogLoggerFactory(); + Log.Info("Log path: " + CurrentLogPath); } - - public static class Reactions + catch (Exception e) { - public static readonly DiscordEmoji Success = DiscordEmoji.FromUnicode("👌"); - public static readonly DiscordEmoji Failure = DiscordEmoji.FromUnicode("⛔"); - public static readonly DiscordEmoji Denied = DiscordEmoji.FromUnicode("👮"); - public static readonly DiscordEmoji Starbucks = DiscordEmoji.FromUnicode("☕"); - public static readonly DiscordEmoji Moderated = DiscordEmoji.FromUnicode("🔨"); - public static readonly DiscordEmoji No = DiscordEmoji.FromUnicode("😐"); - public static readonly DiscordEmoji PleaseWait = DiscordEmoji.FromUnicode("👀"); - public static readonly DiscordEmoji PiracyCheck = DiscordEmoji.FromUnicode("🔨"); - public static readonly DiscordEmoji Shutup = DiscordEmoji.FromUnicode("🔇"); - public static readonly DiscordEmoji BadUpdate = DiscordEmoji.FromUnicode("⚠\ufe0f"); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Error initializing settings: " + e.Message); + Console.ResetColor(); + throw; } + } - public static class Moderation + internal static void RebuildConfiguration() + { + config = new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) // lower priority + .AddEnvironmentVariables() + .AddInMemoryCollection(InMemorySettings) // higher priority + .Build(); + } + + private static ILogger GetLog() + { + var loggingConfig = new NLog.Config.LoggingConfiguration(); + var fileTarget = new FileTarget("logfile") { + FileName = CurrentLogPath, + ArchiveEvery = FileArchivePeriod.Day, + ArchiveNumbering = ArchiveNumberingMode.DateAndSequence, + KeepFileOpen = true, + ConcurrentWrites = false, + AutoFlush = false, + OpenFileFlushTimeout = 1, + Layout = "${longdate} ${sequenceid:padding=6} ${level:uppercase=true:padding=-5} ${message} ${onexception:" + + "${newline}${exception:format=ToString}" + + ":when=not contains('${exception:format=ShortType}','TaskCanceledException')}", + }; + var asyncFileTarget = new AsyncTargetWrapper(fileTarget) { - public static readonly int StarbucksThreshold = 5; - - public static readonly IReadOnlyList Channels = new List - { - 272875751773306881, // #media - 319224795785068545, - }.AsReadOnly(); - - public static readonly IReadOnlyCollection OcrChannels = new HashSet(Channels) - { - 272035812277878785, // #rpcs3 - 277227681836302338, // #help - 272875751773306881, // #media - // test server - 564846659109126244, // #media - 534749301797158914, // private-spam - }; - - public static readonly IReadOnlyCollection RoleWhiteList = new HashSet(StringComparer.InvariantCultureIgnoreCase) - { - "Administrator", - "Community Manager", - "Web Developer", - "Moderator", - "Lead Graphics Developer", - "Lead Core Developer", - "Developers", - "Affiliated", - }; - - public static readonly IReadOnlyCollection RoleSmartList = new HashSet(RoleWhiteList, StringComparer.InvariantCultureIgnoreCase) - { - "Testers", - "Helpers", - "Contributors", - }; - - public static readonly IReadOnlyCollection SupporterRoleList = new HashSet(StringComparer.InvariantCultureIgnoreCase) - { - "Fans", - "Supporters", - "Spectators", - "Nitro Booster", - }; - } - - static Config() + TimeToSleepBetweenBatches = 0, + OverflowAction = AsyncTargetWrapperOverflowAction.Block, + BatchSize = 500, + }; + var consoleTarget = new ColoredConsoleTarget("logconsole") { + Layout = "${longdate} ${level:uppercase=true:padding=-5} ${message} ${onexception:" + + "${newline}${exception:format=Message}" + + ":when=not contains('${exception:format=ShortType}','TaskCanceledException')}", + }; + var watchdogTarget = new MethodCallTarget("watchdog") { - try - { - RebuildConfiguration(); - Log = GetLog(); - LoggerFactory = new NLogLoggerFactory(); - Log.Info("Log path: " + CurrentLogPath); - } - catch (Exception e) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("Error initializing settings: " + e.Message); - Console.ResetColor(); - throw; - } - } - - internal static void RebuildConfiguration() + ClassName = typeof(Watchdog).AssemblyQualifiedName, + MethodName = nameof(Watchdog.OnLogHandler), + }; + watchdogTarget.Parameters.AddRange(new[] { - config = new ConfigurationBuilder() - .AddUserSecrets(Assembly.GetExecutingAssembly()) // lower priority - .AddEnvironmentVariables() - .AddInMemoryCollection(InMemorySettings) // higher priority - .Build(); - } - - private static ILogger GetLog() - { - var loggingConfig = new NLog.Config.LoggingConfiguration(); - var fileTarget = new FileTarget("logfile") { - FileName = CurrentLogPath, - ArchiveEvery = FileArchivePeriod.Day, - ArchiveNumbering = ArchiveNumberingMode.DateAndSequence, - KeepFileOpen = true, - ConcurrentWrites = false, - AutoFlush = false, - OpenFileFlushTimeout = 1, - Layout = "${longdate} ${sequenceid:padding=6} ${level:uppercase=true:padding=-5} ${message} ${onexception:" + - "${newline}${exception:format=ToString}" + - ":when=not contains('${exception:format=ShortType}','TaskCanceledException')}", - }; - var asyncFileTarget = new AsyncTargetWrapper(fileTarget) - { - TimeToSleepBetweenBatches = 0, - OverflowAction = AsyncTargetWrapperOverflowAction.Block, - BatchSize = 500, - }; - var consoleTarget = new ColoredConsoleTarget("logconsole") { - Layout = "${longdate} ${level:uppercase=true:padding=-5} ${message} ${onexception:" + - "${newline}${exception:format=Message}" + - ":when=not contains('${exception:format=ShortType}','TaskCanceledException')}", - }; - var watchdogTarget = new MethodCallTarget("watchdog") - { - ClassName = typeof(Watchdog).AssemblyQualifiedName, - MethodName = nameof(Watchdog.OnLogHandler), - }; - watchdogTarget.Parameters.AddRange(new[] - { - new MethodCallParameter("${level}"), - new MethodCallParameter("${message}"), - }); + new MethodCallParameter("${level}"), + new MethodCallParameter("${message}"), + }); #if DEBUG - loggingConfig.AddRule(LogLevel.Trace, LogLevel.Fatal, consoleTarget, "default"); // only echo messages from default logger to the console + loggingConfig.AddRule(LogLevel.Trace, LogLevel.Fatal, consoleTarget, "default"); // only echo messages from default logger to the console #else loggingConfig.AddRule(LogLevel.Info, LogLevel.Fatal, consoleTarget, "default"); #endif - loggingConfig.AddRule(LogLevel.Debug, LogLevel.Fatal, asyncFileTarget); - loggingConfig.AddRule(LogLevel.Info, LogLevel.Fatal, watchdogTarget); + loggingConfig.AddRule(LogLevel.Debug, LogLevel.Fatal, asyncFileTarget); + loggingConfig.AddRule(LogLevel.Info, LogLevel.Fatal, watchdogTarget); - var ignoreFilter1 = new ConditionBasedFilter { Condition = "contains('${message}','TaskCanceledException')", Action = FilterResult.Ignore, }; - var ignoreFilter2 = new ConditionBasedFilter { Condition = "contains('${message}','One or more pre-execution checks failed')", Action = FilterResult.Ignore, }; - foreach (var rule in loggingConfig.LoggingRules) - { - rule.Filters.Add(ignoreFilter1); - rule.Filters.Add(ignoreFilter2); - rule.FilterDefaultAction = FilterResult.Log; - } - LogManager.Configuration = loggingConfig; - return LogManager.GetLogger("default"); - } - - public static BuildHttpClient? GetAzureDevOpsClient() + var ignoreFilter1 = new ConditionBasedFilter { Condition = "contains('${message}','TaskCanceledException')", Action = FilterResult.Ignore, }; + var ignoreFilter2 = new ConditionBasedFilter { Condition = "contains('${message}','One or more pre-execution checks failed')", Action = FilterResult.Ignore, }; + foreach (var rule in loggingConfig.LoggingRules) { - if (string.IsNullOrEmpty(AzureDevOpsToken)) + rule.Filters.Add(ignoreFilter1); + rule.Filters.Add(ignoreFilter2); + rule.FilterDefaultAction = FilterResult.Log; + } + LogManager.Configuration = loggingConfig; + return LogManager.GetLogger("default"); + } + + public static BuildHttpClient? GetAzureDevOpsClient() + { + if (string.IsNullOrEmpty(AzureDevOpsToken)) + return null; + + var azureCreds = new VssBasicCredential("bot", AzureDevOpsToken); + var azureConnection = new VssConnection(new Uri("https://dev.azure.com/nekotekina"), azureCreds); + return azureConnection.GetClient(); + } + + public static TelemetryClient? TelemetryClient + { + get + { + if (string.IsNullOrEmpty(AzureAppInsightsKey)) return null; - var azureCreds = new VssBasicCredential("bot", AzureDevOpsToken); - var azureConnection = new VssConnection(new Uri("https://dev.azure.com/nekotekina"), azureCreds); - return azureConnection.GetClient(); - } + if (telemetryClient?.InstrumentationKey == AzureAppInsightsKey) + return telemetryClient; - public static TelemetryClient? TelemetryClient - { - get - { - if (string.IsNullOrEmpty(AzureAppInsightsKey)) - return null; - - if (telemetryClient?.InstrumentationKey == AzureAppInsightsKey) - return telemetryClient; - - var telemetryConfig = TelemetryConfiguration.CreateDefault(); - telemetryConfig.InstrumentationKey = AzureAppInsightsKey; - telemetryConfig.TelemetryInitializers.Add(new HttpDependenciesParsingTelemetryInitializer()); - DependencyTrackingTelemetryModule.Initialize(telemetryConfig); - PerformanceCollectorModule.Initialize(telemetryConfig); - return telemetryClient = new TelemetryClient(telemetryConfig); - } + var telemetryConfig = TelemetryConfiguration.CreateDefault(); + telemetryConfig.InstrumentationKey = AzureAppInsightsKey; + telemetryConfig.TelemetryInitializers.Add(new HttpDependenciesParsingTelemetryInitializer()); + DependencyTrackingTelemetryModule.Initialize(telemetryConfig); + PerformanceCollectorModule.Initialize(telemetryConfig); + return telemetryClient = new TelemetryClient(telemetryConfig); } } } \ No newline at end of file diff --git a/CompatBot/Database/BotDb.cs b/CompatBot/Database/BotDb.cs index f65ffdf9..f3e3dc5a 100644 --- a/CompatBot/Database/BotDb.cs +++ b/CompatBot/Database/BotDb.cs @@ -4,195 +4,194 @@ using System.ComponentModel.DataAnnotations.Schema; using CompatApiClient; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Database +namespace CompatBot.Database; + +internal class BotDb: DbContext { - internal class BotDb: DbContext + public DbSet BotState { get; set; } = null!; + public DbSet Moderator { get; set; } = null!; + public DbSet Piracystring { get; set; } = null!; + public DbSet SuspiciousString { get; set; } = null!; + public DbSet Warning { get; set; } = null!; + public DbSet Explanation { get; set; } = null!; + public DbSet DisabledCommands { get; set; } = null!; + public DbSet WhitelistedInvites { get; set; } = null!; + public DbSet EventSchedule { get; set; } = null!; + public DbSet Stats { get; set; } = null!; + public DbSet Kot { get; set; } = null!; + public DbSet Doggo { get; set; } = null!; + public DbSet ForcedNicknames { get; set; } = null!; + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - public DbSet BotState { get; set; } = null!; - public DbSet Moderator { get; set; } = null!; - public DbSet Piracystring { get; set; } = null!; - public DbSet SuspiciousString { get; set; } = null!; - public DbSet Warning { get; set; } = null!; - public DbSet Explanation { get; set; } = null!; - public DbSet DisabledCommands { get; set; } = null!; - public DbSet WhitelistedInvites { get; set; } = null!; - public DbSet EventSchedule { get; set; } = null!; - public DbSet Stats { get; set; } = null!; - public DbSet Kot { get; set; } = null!; - public DbSet Doggo { get; set; } = null!; - public DbSet ForcedNicknames { get; set; } = null!; - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - var dbPath = DbImporter.GetDbPath("bot.db", Environment.SpecialFolder.ApplicationData); + var dbPath = DbImporter.GetDbPath("bot.db", Environment.SpecialFolder.ApplicationData); #if DEBUG - optionsBuilder.UseLoggerFactory(Config.LoggerFactory); + optionsBuilder.UseLoggerFactory(Config.LoggerFactory); #endif - optionsBuilder.UseSqlite($"Data Source=\"{dbPath}\""); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - //configure indices - modelBuilder.Entity().HasIndex(m => m.Key).IsUnique().HasDatabaseName("bot_state_key"); - modelBuilder.Entity().HasIndex(m => m.DiscordId).IsUnique().HasDatabaseName("moderator_discord_id"); - modelBuilder.Entity().Property(ps => ps.Context).HasDefaultValue(FilterContext.Chat | FilterContext.Log); - modelBuilder.Entity().Property(ps => ps.Actions).HasDefaultValue(FilterAction.RemoveContent | FilterAction.IssueWarning | FilterAction.SendMessage); - modelBuilder.Entity().HasIndex(ps => ps.String).HasDatabaseName("piracystring_string"); - modelBuilder.Entity().HasIndex(ss => ss.String).HasDatabaseName("suspicious_string_string"); - modelBuilder.Entity().HasIndex(w => w.DiscordId).HasDatabaseName("warning_discord_id"); - modelBuilder.Entity().HasIndex(e => e.Keyword).IsUnique().HasDatabaseName("explanation_keyword"); - modelBuilder.Entity().HasIndex(c => c.Command).IsUnique().HasDatabaseName("disabled_command_command"); - modelBuilder.Entity().HasIndex(i => i.GuildId).IsUnique().HasDatabaseName("whitelisted_invite_guild_id"); - modelBuilder.Entity().HasIndex(e => new {e.Year, e.EventName}).HasDatabaseName("event_schedule_year_event_name"); - modelBuilder.Entity().HasIndex(s => new { s.Category, s.Key }).IsUnique().HasDatabaseName("stats_category_key"); - modelBuilder.Entity().HasIndex(k => k.UserId).IsUnique().HasDatabaseName("kot_user_id"); - modelBuilder.Entity().HasIndex(d => d.UserId).IsUnique().HasDatabaseName("doggo_user_id"); - modelBuilder.Entity().HasIndex(d => new { d.GuildId, d.UserId }).IsUnique().HasDatabaseName("forced_nickname_guild_id_user_id"); - - //configure default policy of Id being the primary key - modelBuilder.ConfigureDefaultPkConvention(); - - //configure name conversion for all configured entities from CamelCase to snake_case - modelBuilder.ConfigureMapping(NamingStyles.Underscore); - } + optionsBuilder.UseSqlite($"Data Source=\"{dbPath}\""); } - internal class BotState + protected override void OnModelCreating(ModelBuilder modelBuilder) { - public int Id { get; set; } - [Required] - public string Key { get; set; } = null!; - public string? Value { get; set; } - } + //configure indices + modelBuilder.Entity().HasIndex(m => m.Key).IsUnique().HasDatabaseName("bot_state_key"); + modelBuilder.Entity().HasIndex(m => m.DiscordId).IsUnique().HasDatabaseName("moderator_discord_id"); + modelBuilder.Entity().Property(ps => ps.Context).HasDefaultValue(FilterContext.Chat | FilterContext.Log); + modelBuilder.Entity().Property(ps => ps.Actions).HasDefaultValue(FilterAction.RemoveContent | FilterAction.IssueWarning | FilterAction.SendMessage); + modelBuilder.Entity().HasIndex(ps => ps.String).HasDatabaseName("piracystring_string"); + modelBuilder.Entity().HasIndex(ss => ss.String).HasDatabaseName("suspicious_string_string"); + modelBuilder.Entity().HasIndex(w => w.DiscordId).HasDatabaseName("warning_discord_id"); + modelBuilder.Entity().HasIndex(e => e.Keyword).IsUnique().HasDatabaseName("explanation_keyword"); + modelBuilder.Entity().HasIndex(c => c.Command).IsUnique().HasDatabaseName("disabled_command_command"); + modelBuilder.Entity().HasIndex(i => i.GuildId).IsUnique().HasDatabaseName("whitelisted_invite_guild_id"); + modelBuilder.Entity().HasIndex(e => new {e.Year, e.EventName}).HasDatabaseName("event_schedule_year_event_name"); + modelBuilder.Entity().HasIndex(s => new { s.Category, s.Key }).IsUnique().HasDatabaseName("stats_category_key"); + modelBuilder.Entity().HasIndex(k => k.UserId).IsUnique().HasDatabaseName("kot_user_id"); + modelBuilder.Entity().HasIndex(d => d.UserId).IsUnique().HasDatabaseName("doggo_user_id"); + modelBuilder.Entity().HasIndex(d => new { d.GuildId, d.UserId }).IsUnique().HasDatabaseName("forced_nickname_guild_id_user_id"); - internal class Moderator - { - public int Id { get; set; } - public ulong DiscordId { get; set; } - public bool Sudoer { get; set; } - } + //configure default policy of Id being the primary key + modelBuilder.ConfigureDefaultPkConvention(); - public class Piracystring - { - public int Id { get; set; } - [Required, Column(TypeName = "varchar(255)")] - public string String { get; set; } = null!; - public string? ValidatingRegex { get; set; } - public FilterContext Context { get; set; } - public FilterAction Actions { get; set; } - public string? ExplainTerm { get; set; } - public string? CustomMessage { get; set; } - public bool Disabled { get; set; } - } - - public class SuspiciousString - { - public int Id { get; set; } - [Required] - public string String { get; set; } = null!; - } - - [Flags] - public enum FilterContext: byte - { - Chat = 0b_0000_0001, - Log = 0b_0000_0010, - } - - [Flags] - public enum FilterAction - { - //None = 0b_0000_0000, do NOT add this - RemoveContent = 0b_0000_0001, - IssueWarning = 0b_0000_0010, - ShowExplain = 0b_0000_0100, - SendMessage = 0b_0000_1000, - MuteModQueue = 0b_0001_0000, - Kick = 0b_0010_0000, - } - - internal class Warning - { - public int Id { get; set; } - public ulong DiscordId { get; set; } - public ulong IssuerId { get; set; } - [Required] - public string Reason { get; set; } = null!; - [Required] - public string FullReason { get; set; } = null!; - public long? Timestamp { get; set; } - public bool Retracted { get; set; } - public ulong? RetractedBy { get; set; } - public string? RetractionReason { get; set; } - public long? RetractionTimestamp { get; set; } - } - - internal class Explanation - { - public int Id { get; set; } - [Required] - public string Keyword { get; set; } = null!; - [Required] - public string Text { get; set; } = null!; - [MaxLength(7*1024*1024)] - public byte[]? Attachment { get; set; } - public string? AttachmentFilename { get; set; } - } - - internal class DisabledCommand - { - public int Id { get; set; } - [Required] - public string Command { get; set; } = null!; - } - - internal class WhitelistedInvite - { - public int Id { get; set; } - public ulong GuildId { get; set; } - public string? Name { get; set; } - public string? InviteCode { get; set; } - } - - internal class EventSchedule - { - public int Id { get; set; } - public int Year { get; set; } - public long Start { get; set; } - public long End { get; set; } - public string? Name { get; set; } - public string? EventName { get; set; } - } - - internal class Stats - { - public int Id { get; set; } - [Required] - public string Category { get; set; } = null!; - [Required] - public string Key { get; set; } = null!; - public int Value { get; set; } - public long ExpirationTimestamp { get; set; } - } - - internal class Kot - { - public int Id { get; set; } - public ulong UserId { get; set; } - } - - internal class Doggo - { - public int Id { get; set; } - public ulong UserId { get; set; } - } - - internal class ForcedNickname - { - public int Id { get; set; } - public ulong GuildId { set; get; } - public ulong UserId { set; get; } - [Required] - public string Nickname { get; set; } = null!; + //configure name conversion for all configured entities from CamelCase to snake_case + modelBuilder.ConfigureMapping(NamingStyles.Underscore); } } + +internal class BotState +{ + public int Id { get; set; } + [Required] + public string Key { get; set; } = null!; + public string? Value { get; set; } +} + +internal class Moderator +{ + public int Id { get; set; } + public ulong DiscordId { get; set; } + public bool Sudoer { get; set; } +} + +public class Piracystring +{ + public int Id { get; set; } + [Required, Column(TypeName = "varchar(255)")] + public string String { get; set; } = null!; + public string? ValidatingRegex { get; set; } + public FilterContext Context { get; set; } + public FilterAction Actions { get; set; } + public string? ExplainTerm { get; set; } + public string? CustomMessage { get; set; } + public bool Disabled { get; set; } +} + +public class SuspiciousString +{ + public int Id { get; set; } + [Required] + public string String { get; set; } = null!; +} + +[Flags] +public enum FilterContext: byte +{ + Chat = 0b_0000_0001, + Log = 0b_0000_0010, +} + +[Flags] +public enum FilterAction +{ + //None = 0b_0000_0000, do NOT add this + RemoveContent = 0b_0000_0001, + IssueWarning = 0b_0000_0010, + ShowExplain = 0b_0000_0100, + SendMessage = 0b_0000_1000, + MuteModQueue = 0b_0001_0000, + Kick = 0b_0010_0000, +} + +internal class Warning +{ + public int Id { get; set; } + public ulong DiscordId { get; set; } + public ulong IssuerId { get; set; } + [Required] + public string Reason { get; set; } = null!; + [Required] + public string FullReason { get; set; } = null!; + public long? Timestamp { get; set; } + public bool Retracted { get; set; } + public ulong? RetractedBy { get; set; } + public string? RetractionReason { get; set; } + public long? RetractionTimestamp { get; set; } +} + +internal class Explanation +{ + public int Id { get; set; } + [Required] + public string Keyword { get; set; } = null!; + [Required] + public string Text { get; set; } = null!; + [MaxLength(7*1024*1024)] + public byte[]? Attachment { get; set; } + public string? AttachmentFilename { get; set; } +} + +internal class DisabledCommand +{ + public int Id { get; set; } + [Required] + public string Command { get; set; } = null!; +} + +internal class WhitelistedInvite +{ + public int Id { get; set; } + public ulong GuildId { get; set; } + public string? Name { get; set; } + public string? InviteCode { get; set; } +} + +internal class EventSchedule +{ + public int Id { get; set; } + public int Year { get; set; } + public long Start { get; set; } + public long End { get; set; } + public string? Name { get; set; } + public string? EventName { get; set; } +} + +internal class Stats +{ + public int Id { get; set; } + [Required] + public string Category { get; set; } = null!; + [Required] + public string Key { get; set; } = null!; + public int Value { get; set; } + public long ExpirationTimestamp { get; set; } +} + +internal class Kot +{ + public int Id { get; set; } + public ulong UserId { get; set; } +} + +internal class Doggo +{ + public int Id { get; set; } + public ulong UserId { get; set; } +} + +internal class ForcedNickname +{ + public int Id { get; set; } + public ulong GuildId { set; get; } + public ulong UserId { set; get; } + [Required] + public string Nickname { get; set; } = null!; +} \ No newline at end of file diff --git a/CompatBot/Database/DbImporter.cs b/CompatBot/Database/DbImporter.cs index 0ca2f807..c3239778 100644 --- a/CompatBot/Database/DbImporter.cs +++ b/CompatBot/Database/DbImporter.cs @@ -11,259 +11,258 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations.Internal; -namespace CompatBot.Database +namespace CompatBot.Database; + +public static class DbImporter { - public static class DbImporter + public static async Task UpgradeAsync(CancellationToken cancellationToken) { - public static async Task UpgradeAsync(CancellationToken cancellationToken) + await using (var db = new BotDb()) + if (!await UpgradeAsync(db, Config.Cts.Token)) + return false; + + await using (var db = new ThumbnailDb()) { - await using (var db = new BotDb()) - if (!await UpgradeAsync(db, Config.Cts.Token)) - return false; + if (!await UpgradeAsync(db, Config.Cts.Token)) + return false; - await using (var db = new ThumbnailDb()) - { - if (!await UpgradeAsync(db, Config.Cts.Token)) - return false; - - if (!await ImportNamesPool(db, Config.Cts.Token)) - return false; - } + if (!await ImportNamesPool(db, Config.Cts.Token)) + return false; + } - await using (var db = new HardwareDb()) - if (!await UpgradeAsync(db, Config.Cts.Token)) - return false; + await using (var db = new HardwareDb()) + if (!await UpgradeAsync(db, Config.Cts.Token)) + return false; - return true; - } + return true; + } - private static async Task UpgradeAsync(DbContext dbContext, CancellationToken cancellationToken) + private static async Task UpgradeAsync(DbContext dbContext, CancellationToken cancellationToken) + { + try { + Config.Log.Info($"Upgrading {dbContext.GetType().Name} database if needed..."); + await dbContext.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + } + catch (SqliteException e) + { + Config.Log.Warn(e, "Database upgrade failed, probably importing an unversioned one."); + if (dbContext is not BotDb botDb) + return false; + + Config.Log.Info("Trying to apply a manual fixup..."); try { - Config.Log.Info($"Upgrading {dbContext.GetType().Name} database if needed..."); - await dbContext.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + await ImportAsync(botDb, cancellationToken).ConfigureAwait(false); + Config.Log.Info("Manual fixup worked great. Let's try migrations again..."); + await botDb.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); } - catch (SqliteException e) + catch (Exception ex) { - Config.Log.Warn(e, "Database upgrade failed, probably importing an unversioned one."); - if (dbContext is not BotDb botDb) - return false; - - Config.Log.Info("Trying to apply a manual fixup..."); - try - { - await ImportAsync(botDb, cancellationToken).ConfigureAwait(false); - Config.Log.Info("Manual fixup worked great. Let's try migrations again..."); - await botDb.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - Config.Log.Fatal(ex, "Well shit, I hope you had backups, son. You'll have to figure this one out on your own."); - return false; - } + Config.Log.Fatal(ex, "Well shit, I hope you had backups, son. You'll have to figure this one out on your own."); + return false; } - Config.Log.Info("Database is ready."); - return true; } + Config.Log.Info("Database is ready."); + return true; + } - private static async Task ImportAsync(BotDb dbContext, CancellationToken cancellationToken) + private static async Task ImportAsync(BotDb dbContext, CancellationToken cancellationToken) + { + var db = dbContext.Database; + await using var tx = await db.BeginTransactionAsync(cancellationToken); + try { - var db = dbContext.Database; - await using var tx = await db.BeginTransactionAsync(cancellationToken); - try - { - // __EFMigrationsHistory table will be already created by the failed migration attempt + // __EFMigrationsHistory table will be already created by the failed migration attempt #pragma warning disable EF1001 // Internal EF Core API usage. - await db.ExecuteSqlRawAsync($"INSERT INTO `__EFMigrationsHistory`(`MigrationId`,`ProductVersion`) VALUES ({new InitialCreate().GetId()},'manual')", cancellationToken); - await db.ExecuteSqlRawAsync($"INSERT INTO `__EFMigrationsHistory`(`MigrationId`,`ProductVersion`) VALUES ({new Explanations().GetId()},'manual')", cancellationToken); + await db.ExecuteSqlRawAsync($"INSERT INTO `__EFMigrationsHistory`(`MigrationId`,`ProductVersion`) VALUES ({new InitialCreate().GetId()},'manual')", cancellationToken); + await db.ExecuteSqlRawAsync($"INSERT INTO `__EFMigrationsHistory`(`MigrationId`,`ProductVersion`) VALUES ({new Explanations().GetId()},'manual')", cancellationToken); #pragma warning restore EF1001 // Internal EF Core API usage. - // create constraints on moderator - await db.ExecuteSqlRawAsync(@"CREATE TABLE `temp_new_moderator` ( + // create constraints on moderator + await db.ExecuteSqlRawAsync(@"CREATE TABLE `temp_new_moderator` ( `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `discord_id` INTEGER NOT NULL, `sudoer` INTEGER NOT NULL )", cancellationToken); - await db.ExecuteSqlRawAsync("INSERT INTO `temp_new_moderator` SELECT `id`,`discord_id`,`sudoer` FROM `moderator`", cancellationToken); - await db.ExecuteSqlRawAsync("DROP TABLE `moderator`", cancellationToken); - await db.ExecuteSqlRawAsync("ALTER TABLE `temp_new_moderator` RENAME TO `moderator`", cancellationToken); - await db.ExecuteSqlRawAsync("CREATE UNIQUE INDEX `moderator_discord_id` ON `moderator` (`discord_id`)", cancellationToken); - // create constraints on piracystring - await db.ExecuteSqlRawAsync(@"CREATE TABLE `temp_new_piracystring` ( + await db.ExecuteSqlRawAsync("INSERT INTO `temp_new_moderator` SELECT `id`,`discord_id`,`sudoer` FROM `moderator`", cancellationToken); + await db.ExecuteSqlRawAsync("DROP TABLE `moderator`", cancellationToken); + await db.ExecuteSqlRawAsync("ALTER TABLE `temp_new_moderator` RENAME TO `moderator`", cancellationToken); + await db.ExecuteSqlRawAsync("CREATE UNIQUE INDEX `moderator_discord_id` ON `moderator` (`discord_id`)", cancellationToken); + // create constraints on piracystring + await db.ExecuteSqlRawAsync(@"CREATE TABLE `temp_new_piracystring` ( `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `string` varchar ( 255 ) NOT NULL )", cancellationToken); - await db.ExecuteSqlRawAsync("INSERT INTO `temp_new_piracystring` SELECT `id`,`string` FROM `piracystring`", cancellationToken); - await db.ExecuteSqlRawAsync("DROP TABLE `piracystring`", cancellationToken); - await db.ExecuteSqlRawAsync("ALTER TABLE `temp_new_piracystring` RENAME TO `piracystring`", cancellationToken); - await db.ExecuteSqlRawAsync("CREATE UNIQUE INDEX `piracystring_string` ON `piracystring` (`string`)", cancellationToken); - // create constraints on warning - await db.ExecuteSqlRawAsync(@"CREATE TABLE `temp_new_warning` ( + await db.ExecuteSqlRawAsync("INSERT INTO `temp_new_piracystring` SELECT `id`,`string` FROM `piracystring`", cancellationToken); + await db.ExecuteSqlRawAsync("DROP TABLE `piracystring`", cancellationToken); + await db.ExecuteSqlRawAsync("ALTER TABLE `temp_new_piracystring` RENAME TO `piracystring`", cancellationToken); + await db.ExecuteSqlRawAsync("CREATE UNIQUE INDEX `piracystring_string` ON `piracystring` (`string`)", cancellationToken); + // create constraints on warning + await db.ExecuteSqlRawAsync(@"CREATE TABLE `temp_new_warning` ( `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `discord_id` INTEGER NOT NULL, `reason` TEXT NOT NULL, `full_reason` TEXT NOT NULL, `issuer_id` INTEGER NOT NULL DEFAULT 0 )", cancellationToken); - await db.ExecuteSqlRawAsync("INSERT INTO `temp_new_warning` SELECT `id`,`discord_id`,`reason`,`full_reason`,`issuer_id` FROM `warning`", cancellationToken); - await db.ExecuteSqlRawAsync("DROP TABLE `warning`", cancellationToken); - await db.ExecuteSqlRawAsync("ALTER TABLE `temp_new_warning` RENAME TO `warning`", cancellationToken); - await db.ExecuteSqlRawAsync("CREATE INDEX `warning_discord_id` ON `warning` (`discord_id`)", cancellationToken); - // create constraints on explanation - await db.ExecuteSqlRawAsync(@"CREATE TABLE `temp_new_explanation` ( + await db.ExecuteSqlRawAsync("INSERT INTO `temp_new_warning` SELECT `id`,`discord_id`,`reason`,`full_reason`,`issuer_id` FROM `warning`", cancellationToken); + await db.ExecuteSqlRawAsync("DROP TABLE `warning`", cancellationToken); + await db.ExecuteSqlRawAsync("ALTER TABLE `temp_new_warning` RENAME TO `warning`", cancellationToken); + await db.ExecuteSqlRawAsync("CREATE INDEX `warning_discord_id` ON `warning` (`discord_id`)", cancellationToken); + // create constraints on explanation + await db.ExecuteSqlRawAsync(@"CREATE TABLE `temp_new_explanation` ( `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `keyword` TEXT NOT NULL, `text` TEXT NOT NULL )", cancellationToken); - await db.ExecuteSqlRawAsync("INSERT INTO `temp_new_explanation` SELECT `id`,`keyword`,`text` FROM `explanation`", cancellationToken); - await db.ExecuteSqlRawAsync("DROP TABLE `explanation`", cancellationToken); - await db.ExecuteSqlRawAsync("ALTER TABLE `temp_new_explanation` RENAME TO `explanation`", cancellationToken); - await db.ExecuteSqlRawAsync("CREATE UNIQUE INDEX `explanation_keyword` ON `explanation` (`keyword`)", cancellationToken); - await tx.CommitAsync(cancellationToken); - } - catch + await db.ExecuteSqlRawAsync("INSERT INTO `temp_new_explanation` SELECT `id`,`keyword`,`text` FROM `explanation`", cancellationToken); + await db.ExecuteSqlRawAsync("DROP TABLE `explanation`", cancellationToken); + await db.ExecuteSqlRawAsync("ALTER TABLE `temp_new_explanation` RENAME TO `explanation`", cancellationToken); + await db.ExecuteSqlRawAsync("CREATE UNIQUE INDEX `explanation_keyword` ON `explanation` (`keyword`)", cancellationToken); + await tx.CommitAsync(cancellationToken); + } + catch + { + await tx.CommitAsync(cancellationToken); + throw; + } + } + + internal static string GetDbPath(string dbName, Environment.SpecialFolder desiredFolder) + { + if (SandboxDetector.Detect() == SandboxType.Docker) + return Path.Combine("/bot-db/", dbName); + + var settingsFolder = Path.Combine(Environment.GetFolderPath(desiredFolder), "compat-bot"); + try + { + if (!Directory.Exists(settingsFolder)) + Directory.CreateDirectory(settingsFolder); + } + catch (Exception e) + { + Config.Log.Error(e, "Failed to create settings folder " + settingsFolder); + settingsFolder = ""; + } + + var dbPath = Path.Combine(settingsFolder, dbName); + if (settingsFolder != "") + try { - await tx.CommitAsync(cancellationToken); + if (File.Exists(dbName)) + { + Config.Log.Info($"Found local {dbName}, moving..."); + if (File.Exists(dbPath)) + { + Config.Log.Error($"{dbPath} already exists, please reslove the conflict manually"); + throw new InvalidOperationException($"Failed to move local {dbName} to {dbPath}"); + } + + var dbFiles = Directory.GetFiles(".", Path.GetFileNameWithoutExtension(dbName) + ".*"); + foreach (var file in dbFiles) + File.Move(file, Path.Combine(settingsFolder, Path.GetFileName(file))); + Config.Log.Info($"Using {dbPath}"); + } + } + catch (Exception e) + { + Config.Log.Error(e, $"Failed to move local {dbName} to {dbPath}"); throw; } + return dbPath; + } + + private static async Task ImportNamesPool(ThumbnailDb db, CancellationToken cancellationToken) + { + Config.Log.Debug("Importing name pool..."); + var rootDir = Environment.CurrentDirectory; + while (rootDir is not null && !Directory.EnumerateFiles(rootDir, "names_*.txt", SearchOption.TopDirectoryOnly).Any()) + rootDir = Path.GetDirectoryName(rootDir); + if (rootDir is null) + { + Config.Log.Error("Couldn't find any name sources"); + return db.NamePool.Any(); } - internal static string GetDbPath(string dbName, Environment.SpecialFolder desiredFolder) + var resources = Directory.GetFiles(rootDir, "names_*.txt", SearchOption.TopDirectoryOnly) + .OrderBy(f => f) + .ToList(); + if (resources.Count == 0) { - if (SandboxDetector.Detect() == SandboxType.Docker) - return Path.Combine("/bot-db/", dbName); - - var settingsFolder = Path.Combine(Environment.GetFolderPath(desiredFolder), "compat-bot"); - try - { - if (!Directory.Exists(settingsFolder)) - Directory.CreateDirectory(settingsFolder); - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to create settings folder " + settingsFolder); - settingsFolder = ""; - } - - var dbPath = Path.Combine(settingsFolder, dbName); - if (settingsFolder != "") - try - { - if (File.Exists(dbName)) - { - Config.Log.Info($"Found local {dbName}, moving..."); - if (File.Exists(dbPath)) - { - Config.Log.Error($"{dbPath} already exists, please reslove the conflict manually"); - throw new InvalidOperationException($"Failed to move local {dbName} to {dbPath}"); - } - - var dbFiles = Directory.GetFiles(".", Path.GetFileNameWithoutExtension(dbName) + ".*"); - foreach (var file in dbFiles) - File.Move(file, Path.Combine(settingsFolder, Path.GetFileName(file))); - Config.Log.Info($"Using {dbPath}"); - } - } - catch (Exception e) - { - Config.Log.Error(e, $"Failed to move local {dbName} to {dbPath}"); - throw; - } - return dbPath; + Config.Log.Error("Couldn't find any name sources (???)"); + return db.NamePool.Any(); } - private static async Task ImportNamesPool(ThumbnailDb db, CancellationToken cancellationToken) + var timestamp = -1L; + using (var sha256 = System.Security.Cryptography.SHA256.Create()) { - Config.Log.Debug("Importing name pool..."); - var rootDir = Environment.CurrentDirectory; - while (rootDir is not null && !Directory.EnumerateFiles(rootDir, "names_*.txt", SearchOption.TopDirectoryOnly).Any()) - rootDir = Path.GetDirectoryName(rootDir); - if (rootDir is null) + byte[] buf; + foreach (var path in resources) { - Config.Log.Error("Couldn't find any name sources"); - return db.NamePool.Any(); + var fileInfo = new FileInfo(path); + buf = BitConverter.GetBytes(fileInfo.Length); + sha256.TransformBlock(buf, 0, buf.Length, null, 0); } + buf = Encoding.UTF8.GetBytes(Config.RenameNameSuffix); + buf = sha256.TransformFinalBlock(buf, 0, buf.Length); + timestamp = BitConverter.ToInt64(buf, 0); + } - var resources = Directory.GetFiles(rootDir, "names_*.txt", SearchOption.TopDirectoryOnly) - .OrderBy(f => f) - .ToList(); - if (resources.Count == 0) - { - Config.Log.Error("Couldn't find any name sources (???)"); - return db.NamePool.Any(); - } + const string renameStateKey = "rename-name-pool"; + var stateEntry = db.State.FirstOrDefault(n => n.Locale == renameStateKey); + if (stateEntry?.Timestamp == timestamp) + { + Config.Log.Info("Name pool is up-to-date"); + return true; + } - var timestamp = -1L; - using (var sha256 = System.Security.Cryptography.SHA256.Create()) + Config.Log.Info("Updating name pool..."); + try + { + var names = new HashSet(); + foreach (var resourcePath in resources) { - byte[] buf; - foreach (var path in resources) + await using var stream = File.Open(resourcePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new StreamReader(stream); + while (await reader.ReadLineAsync().ConfigureAwait(false) is string line) { - var fileInfo = new FileInfo(path); - buf = BitConverter.GetBytes(fileInfo.Length); - sha256.TransformBlock(buf, 0, buf.Length, null, 0); + if (line.Length < 2 || line.StartsWith("#")) + continue; + + var commentPos = line.IndexOf(" ("); + if (commentPos > 1) + line = line.Substring(0, commentPos); + line = line.Trim() + .Replace(" ", " ") + .Replace('`', '\'') // consider ’ + .Replace("\"", "\\\""); + if (line.Length + Config.RenameNameSuffix.Length > 32) + continue; + + if (line.Contains('@') + || line.Contains('#') + || line.Contains(':')) + continue; + + names.Add(line); } - buf = Encoding.UTF8.GetBytes(Config.RenameNameSuffix); - buf = sha256.TransformFinalBlock(buf, 0, buf.Length); - timestamp = BitConverter.ToInt64(buf, 0); - } - - const string renameStateKey = "rename-name-pool"; - var stateEntry = db.State.FirstOrDefault(n => n.Locale == renameStateKey); - if (stateEntry?.Timestamp == timestamp) - { - Config.Log.Info("Name pool is up-to-date"); - return true; - } - - Config.Log.Info("Updating name pool..."); - try - { - var names = new HashSet(); - foreach (var resourcePath in resources) - { - await using var stream = File.Open(resourcePath, FileMode.Open, FileAccess.Read, FileShare.Read); - using var reader = new StreamReader(stream); - while (await reader.ReadLineAsync().ConfigureAwait(false) is string line) - { - if (line.Length < 2 || line.StartsWith("#")) - continue; - - var commentPos = line.IndexOf(" ("); - if (commentPos > 1) - line = line.Substring(0, commentPos); - line = line.Trim() - .Replace(" ", " ") - .Replace('`', '\'') // consider ’ - .Replace("\"", "\\\""); - if (line.Length + Config.RenameNameSuffix.Length > 32) - continue; - - if (line.Contains('@') - || line.Contains('#') - || line.Contains(':')) - continue; - - names.Add(line); - } - } - await using var tx = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); - db.NamePool.RemoveRange(db.NamePool); - foreach (var name in names) - await db.NamePool.AddAsync(new() {Name = name}, cancellationToken).ConfigureAwait(false); - if (stateEntry is null) - await db.State.AddAsync(new() {Locale = renameStateKey, Timestamp = timestamp}, cancellationToken).ConfigureAwait(false); - else - stateEntry.Timestamp = timestamp; - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - await tx.CommitAsync(cancellationToken).ConfigureAwait(false); - return names.Count > 0; - } - catch (Exception e) - { - Config.Log.Error(e); - return false; } + await using var tx = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + db.NamePool.RemoveRange(db.NamePool); + foreach (var name in names) + await db.NamePool.AddAsync(new() {Name = name}, cancellationToken).ConfigureAwait(false); + if (stateEntry is null) + await db.State.AddAsync(new() {Locale = renameStateKey, Timestamp = timestamp}, cancellationToken).ConfigureAwait(false); + else + stateEntry.Timestamp = timestamp; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + return names.Count > 0; + } + catch (Exception e) + { + Config.Log.Error(e); + return false; } } } \ No newline at end of file diff --git a/CompatBot/Database/NamingConventionConverter.cs b/CompatBot/Database/NamingConventionConverter.cs index d8bb13bd..b168eb76 100644 --- a/CompatBot/Database/NamingConventionConverter.cs +++ b/CompatBot/Database/NamingConventionConverter.cs @@ -1,31 +1,30 @@ using System; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Database -{ - internal static class NamingConventionConverter - { - public static void ConfigureMapping(this ModelBuilder modelBuilder, Func nameResolver) - { - if (nameResolver == null) - throw new ArgumentNullException(nameof(nameResolver)); +namespace CompatBot.Database; - foreach (var entity in modelBuilder.Model.GetEntityTypes()) - { - if (entity.GetTableName() is string tableName) - entity.SetTableName(nameResolver(tableName)); - foreach (var property in entity.GetProperties()) - property.SetColumnName(nameResolver(property.Name)); - foreach (var key in entity.GetKeys()) - if (key.GetName() is string name) - key.SetName(nameResolver(name)); - foreach (var key in entity.GetForeignKeys()) - if (key.GetConstraintName() is string constraint) - key.SetConstraintName(nameResolver(constraint)); - foreach (var index in entity.GetIndexes()) - if (index.GetDatabaseName() is string dbName) - index.SetDatabaseName(nameResolver(dbName)); - } +internal static class NamingConventionConverter +{ + public static void ConfigureMapping(this ModelBuilder modelBuilder, Func nameResolver) + { + if (nameResolver == null) + throw new ArgumentNullException(nameof(nameResolver)); + + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + if (entity.GetTableName() is string tableName) + entity.SetTableName(nameResolver(tableName)); + foreach (var property in entity.GetProperties()) + property.SetColumnName(nameResolver(property.Name)); + foreach (var key in entity.GetKeys()) + if (key.GetName() is string name) + key.SetName(nameResolver(name)); + foreach (var key in entity.GetForeignKeys()) + if (key.GetConstraintName() is string constraint) + key.SetConstraintName(nameResolver(constraint)); + foreach (var index in entity.GetIndexes()) + if (index.GetDatabaseName() is string dbName) + index.SetDatabaseName(nameResolver(dbName)); } } -} +} \ No newline at end of file diff --git a/CompatBot/Database/PrimaryKeyConvention.cs b/CompatBot/Database/PrimaryKeyConvention.cs index 08e60dbc..0e81da4d 100644 --- a/CompatBot/Database/PrimaryKeyConvention.cs +++ b/CompatBot/Database/PrimaryKeyConvention.cs @@ -2,30 +2,29 @@ using System.Linq; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Database +namespace CompatBot.Database; + +internal static class PrimaryKeyConvention { - internal static class PrimaryKeyConvention + public static void ConfigureDefaultPkConvention(this ModelBuilder modelBuilder, string keyProperty = "Id") { - public static void ConfigureDefaultPkConvention(this ModelBuilder modelBuilder, string keyProperty = "Id") - { - if (string.IsNullOrEmpty(keyProperty)) - throw new ArgumentException("Key property name is mandatory", nameof(keyProperty)); + if (string.IsNullOrEmpty(keyProperty)) + throw new ArgumentException("Key property name is mandatory", nameof(keyProperty)); - foreach (var entity in modelBuilder.Model.GetEntityTypes()) - { - var pk = entity.GetKeys().FirstOrDefault(k => k.IsPrimaryKey()); - pk?.SetName(keyProperty); - } + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + var pk = entity.GetKeys().FirstOrDefault(k => k.IsPrimaryKey()); + pk?.SetName(keyProperty); } + } - public static void ConfigureNoPkConvention(this ModelBuilder modelBuilder) + public static void ConfigureNoPkConvention(this ModelBuilder modelBuilder) + { + foreach (var entity in modelBuilder.Model.GetEntityTypes()) { - foreach (var entity in modelBuilder.Model.GetEntityTypes()) - { - var pk = entity.GetKeys().FirstOrDefault(k => k.IsPrimaryKey()); - if (pk != null) - entity.RemoveKey(pk.Properties); - } + var pk = entity.GetKeys().FirstOrDefault(k => k.IsPrimaryKey()); + if (pk != null) + entity.RemoveKey(pk.Properties); } } } \ No newline at end of file diff --git a/CompatBot/Database/Providers/AmdDriverVersionProvider.cs b/CompatBot/Database/Providers/AmdDriverVersionProvider.cs index 33318667..f30b8797 100644 --- a/CompatBot/Database/Providers/AmdDriverVersionProvider.cs +++ b/CompatBot/Database/Providers/AmdDriverVersionProvider.cs @@ -7,164 +7,163 @@ using System.Threading.Tasks; using System.Xml.Linq; using CompatApiClient.Compression; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +internal static class AmdDriverVersionProvider { - internal static class AmdDriverVersionProvider + private static readonly Dictionary> VulkanToDriver = new(); + private static readonly Dictionary OpenglToDriver = new(); + private static readonly SemaphoreSlim SyncObj = new(1, 1); + + public static async Task RefreshAsync() { - private static readonly Dictionary> VulkanToDriver = new(); - private static readonly Dictionary OpenglToDriver = new(); - private static readonly SemaphoreSlim SyncObj = new(1, 1); - - public static async Task RefreshAsync() - { - if (await SyncObj.WaitAsync(0).ConfigureAwait(false)) - try + if (await SyncObj.WaitAsync(0).ConfigureAwait(false)) + try + { + using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); + await using var response = await httpClient.GetStreamAsync("https://raw.githubusercontent.com/GPUOpen-Drivers/amd-vulkan-versions/master/amdversions.xml").ConfigureAwait(false); + var xml = await XDocument.LoadAsync(response, LoadOptions.None, Config.Cts.Token).ConfigureAwait(false); + if (xml.Root is null) { - using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler()); - await using var response = await httpClient.GetStreamAsync("https://raw.githubusercontent.com/GPUOpen-Drivers/amd-vulkan-versions/master/amdversions.xml").ConfigureAwait(false); - var xml = await XDocument.LoadAsync(response, LoadOptions.None, Config.Cts.Token).ConfigureAwait(false); - if (xml.Root is null) - { - Config.Log.Warn("Failed to update AMD version mapping"); - return; - } + Config.Log.Warn("Failed to update AMD version mapping"); + return; + } - foreach (var driver in xml.Root.Elements("driver")) - { - var winVer = (string?)driver.Element("windows-version"); - var vkVer = (string?)driver.Element("vulkan-version"); - var driverVer = (string?)driver.Attribute("version"); - if (vkVer is null) - continue; + foreach (var driver in xml.Root.Elements("driver")) + { + var winVer = (string?)driver.Element("windows-version"); + var vkVer = (string?)driver.Element("vulkan-version"); + var driverVer = (string?)driver.Attribute("version"); + if (vkVer is null) + continue; - if (!VulkanToDriver.TryGetValue(vkVer, out var verList)) - VulkanToDriver[vkVer] = verList = new List(); - if (string.IsNullOrEmpty(driverVer)) - continue; + if (!VulkanToDriver.TryGetValue(vkVer, out var verList)) + VulkanToDriver[vkVer] = verList = new List(); + if (string.IsNullOrEmpty(driverVer)) + continue; - verList.Insert(0, driverVer); - if (!string.IsNullOrEmpty(winVer)) - OpenglToDriver[winVer] = driverVer; - } - foreach (var key in VulkanToDriver.Keys.ToList()) - VulkanToDriver[key] = VulkanToDriver[key].Distinct().ToList(); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to update AMD version mapping"); - } - finally - { - SyncObj.Release(); + verList.Insert(0, driverVer); + if (!string.IsNullOrEmpty(winVer)) + OpenglToDriver[winVer] = driverVer; } + foreach (var key in VulkanToDriver.Keys.ToList()) + VulkanToDriver[key] = VulkanToDriver[key].Distinct().ToList(); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to update AMD version mapping"); + } + finally + { + SyncObj.Release(); + } + } + + public static async Task GetFromOpenglAsync(string openglVersion, bool autoRefresh = true) + { + if (OpenglToDriver.TryGetValue(openglVersion, out var result)) + return result; + + if (!Version.TryParse(openglVersion, out var glVersion)) + return openglVersion; + + var glVersions = new List<(Version glVer, string driverVer)>(OpenglToDriver.Count); + foreach (var key in OpenglToDriver.Keys) + { + if (Version.TryParse(key, out var ver)) + glVersions.Add((ver, OpenglToDriver[key])); + } + if (glVersions.Count == 0) + return openglVersion; + + glVersions.Sort((l, r) => l.glVer < r.glVer ? -1 : l.glVer > r.glVer ? 1 : 0); + if (glVersion < glVersions[0].glVer) + return $"older than {glVersions[0].driverVer} ({openglVersion})"; + + var newest = glVersions.Last(); + if (glVersion > newest.glVer) + { + if (autoRefresh) + { + await RefreshAsync().ConfigureAwait(false); + return await GetFromOpenglAsync(openglVersion, false).ConfigureAwait(false); + } + + return $"newer than {newest.driverVer} ({openglVersion})"; } - public static async Task GetFromOpenglAsync(string openglVersion, bool autoRefresh = true) - { - if (OpenglToDriver.TryGetValue(openglVersion, out var result)) - return result; + var approximate = glVersions.FirstOrDefault(v => v.glVer.Minor == glVersion.Minor && v.glVer.Build == glVersion.Build); + if (!string.IsNullOrEmpty(approximate.driverVer)) + return $"{approximate.driverVer} rev {glVersion.Revision}"; - if (!Version.TryParse(openglVersion, out var glVersion)) - return openglVersion; - - var glVersions = new List<(Version glVer, string driverVer)>(OpenglToDriver.Count); - foreach (var key in OpenglToDriver.Keys) + if (string.IsNullOrEmpty(approximate.driverVer)) + for (var i = 0; i < glVersions.Count - 1; i++) + if (glVersion > glVersions[i].glVer && glVersion < glVersions[i + 1].glVer) + { + approximate = glVersions[i]; + break; + } + if (!string.IsNullOrEmpty(approximate.driverVer)) + return $"probably {approximate.driverVer}"; + + return openglVersion; + } + + public static async Task GetFromVulkanAsync(string vulkanVersion, bool autoRefresh = true) + { + if (!VulkanToDriver.TryGetValue(vulkanVersion, out var result)) + await RefreshAsync().ConfigureAwait(false); + + if (result?.Count > 0 || (VulkanToDriver.TryGetValue(vulkanVersion, out result) && result.Count > 0)) + { + if (result.Count == 1) + return result[0]; + return $"{result.First()} - {result.Last()}"; + } + + if (Version.TryParse(vulkanVersion, out var vkVer)) + { + var vkVersions = new List<(Version vkVer, string driverVer)>(VulkanToDriver.Count); + foreach (var key in VulkanToDriver.Keys) { if (Version.TryParse(key, out var ver)) - glVersions.Add((ver, OpenglToDriver[key])); + vkVersions.Add((ver, VulkanToDriver[key].First())); } - if (glVersions.Count == 0) - return openglVersion; + if (vkVersions.Count == 0) + return vulkanVersion; - glVersions.Sort((l, r) => l.glVer < r.glVer ? -1 : l.glVer > r.glVer ? 1 : 0); - if (glVersion < glVersions[0].glVer) - return $"older than {glVersions[0].driverVer} ({openglVersion})"; + vkVersions.Sort((l, r) => l.vkVer < r.vkVer ? -1 : l.vkVer > r.vkVer ? 1 : 0); + if (vkVer < vkVersions[0].vkVer) + return $"older than {vkVersions[0].driverVer} ({vulkanVersion})"; - var newest = glVersions.Last(); - if (glVersion > newest.glVer) + var (version, driverVer) = vkVersions.Last(); + if (vkVer > version) { - if (autoRefresh) - { - await RefreshAsync().ConfigureAwait(false); - return await GetFromOpenglAsync(openglVersion, false).ConfigureAwait(false); - } - - return $"newer than {newest.driverVer} ({openglVersion})"; - } - - var approximate = glVersions.FirstOrDefault(v => v.glVer.Minor == glVersion.Minor && v.glVer.Build == glVersion.Build); - if (!string.IsNullOrEmpty(approximate.driverVer)) - return $"{approximate.driverVer} rev {glVersion.Revision}"; - - if (string.IsNullOrEmpty(approximate.driverVer)) - for (var i = 0; i < glVersions.Count - 1; i++) - if (glVersion > glVersions[i].glVer && glVersion < glVersions[i + 1].glVer) - { - approximate = glVersions[i]; - break; - } - if (!string.IsNullOrEmpty(approximate.driverVer)) - return $"probably {approximate.driverVer}"; - - return openglVersion; - } - - public static async Task GetFromVulkanAsync(string vulkanVersion, bool autoRefresh = true) - { - if (!VulkanToDriver.TryGetValue(vulkanVersion, out var result)) + if (!autoRefresh) + return $"newer than {driverVer} ({vulkanVersion})"; + await RefreshAsync().ConfigureAwait(false); - - if (result?.Count > 0 || (VulkanToDriver.TryGetValue(vulkanVersion, out result) && result.Count > 0)) - { - if (result.Count == 1) - return result[0]; - return $"{result.First()} - {result.Last()}"; + return await GetFromVulkanAsync(vulkanVersion, false).ConfigureAwait(false); } - - if (Version.TryParse(vulkanVersion, out var vkVer)) - { - var vkVersions = new List<(Version vkVer, string driverVer)>(VulkanToDriver.Count); - foreach (var key in VulkanToDriver.Keys) - { - if (Version.TryParse(key, out var ver)) - vkVersions.Add((ver, VulkanToDriver[key].First())); - } - if (vkVersions.Count == 0) - return vulkanVersion; - - vkVersions.Sort((l, r) => l.vkVer < r.vkVer ? -1 : l.vkVer > r.vkVer ? 1 : 0); - if (vkVer < vkVersions[0].vkVer) - return $"older than {vkVersions[0].driverVer} ({vulkanVersion})"; - - var (version, driverVer) = vkVersions.Last(); - if (vkVer > version) - { - if (!autoRefresh) - return $"newer than {driverVer} ({vulkanVersion})"; - - await RefreshAsync().ConfigureAwait(false); - return await GetFromVulkanAsync(vulkanVersion, false).ConfigureAwait(false); - } - for (var i = 1; i < vkVersions.Count; i++) - { - if (vkVer >= vkVersions[i].vkVer) - continue; + for (var i = 1; i < vkVersions.Count; i++) + { + if (vkVer >= vkVersions[i].vkVer) + continue; - var lowerVer = vkVersions[i - 1].vkVer; - var mapKey = VulkanToDriver.Keys.FirstOrDefault(k => Version.Parse(k) == lowerVer); - if (mapKey is null) - continue; + var lowerVer = vkVersions[i - 1].vkVer; + var mapKey = VulkanToDriver.Keys.FirstOrDefault(k => Version.Parse(k) == lowerVer); + if (mapKey is null) + continue; - if (!VulkanToDriver.TryGetValue(mapKey, out var driverList)) - continue; + if (!VulkanToDriver.TryGetValue(mapKey, out var driverList)) + continue; - var oldestLowerVersion = driverList.Select(Version.Parse).OrderByDescending(v => v).First(); - return $"unknown version between {oldestLowerVersion} and {vkVersions[i].driverVer} ({vulkanVersion})"; - } + var oldestLowerVersion = driverList.Select(Version.Parse).OrderByDescending(v => v).First(); + return $"unknown version between {oldestLowerVersion} and {vkVersions[i].driverVer} ({vulkanVersion})"; } - - return vulkanVersion; } + + return vulkanVersion; } -} +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/ContentFilter.cs b/CompatBot/Database/Providers/ContentFilter.cs index 38cb42bd..0edeee46 100644 --- a/CompatBot/Database/Providers/ContentFilter.cs +++ b/CompatBot/Database/Providers/ContentFilter.cs @@ -13,114 +13,114 @@ using Microsoft.EntityFrameworkCore; using NReco.Text; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +internal static class ContentFilter { - internal static class ContentFilter + private static Dictionary?> filters = new(); + private static readonly MemoryCache ResponseAntispamCache = new(new MemoryCacheOptions{ ExpirationScanFrequency = TimeSpan.FromMinutes(5)}); + private static readonly MemoryCache ReportAntispamCache = new(new MemoryCacheOptions{ ExpirationScanFrequency = TimeSpan.FromMinutes(5)}); + private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(15); + + static ContentFilter() => RebuildMatcher(); + + public static Task FindTriggerAsync(FilterContext ctx, string str) { - private static Dictionary?> filters = new(); - private static readonly MemoryCache ResponseAntispamCache = new(new MemoryCacheOptions{ ExpirationScanFrequency = TimeSpan.FromMinutes(5)}); - private static readonly MemoryCache ReportAntispamCache = new(new MemoryCacheOptions{ ExpirationScanFrequency = TimeSpan.FromMinutes(5)}); - private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(15); + if (string.IsNullOrEmpty(str)) + return Task.FromResult((Piracystring?)null); - static ContentFilter() => RebuildMatcher(); + if (!filters.TryGetValue(ctx, out var matcher)) + return Task.FromResult((Piracystring?)null); - public static Task FindTriggerAsync(FilterContext ctx, string str) + Piracystring? result = null; + matcher?.ParseText(str, h => { - if (string.IsNullOrEmpty(str)) - return Task.FromResult((Piracystring?)null); + if (string.IsNullOrEmpty(h.Value.ValidatingRegex) || Regex.IsMatch(str, h.Value.ValidatingRegex, RegexOptions.IgnoreCase | RegexOptions.Multiline)) + { + result = h.Value; + Config.Log.Info($"Triggered content filter #{h.Value.Id} ({h.Value.String}; regex={h.Value.ValidatingRegex}) at idx {h.Begin} of message string '{str}'"); + return !h.Value.Actions.HasFlag(FilterAction.IssueWarning); + } + return true; + }); - if (!filters.TryGetValue(ctx, out var matcher)) - return Task.FromResult((Piracystring?)null); - - Piracystring? result = null; + if (result is null && ctx == FilterContext.Chat) + { + str = str.StripInvisibleAndDiacritics(); matcher?.ParseText(str, h => { if (string.IsNullOrEmpty(h.Value.ValidatingRegex) || Regex.IsMatch(str, h.Value.ValidatingRegex, RegexOptions.IgnoreCase | RegexOptions.Multiline)) { result = h.Value; - Config.Log.Info($"Triggered content filter #{h.Value.Id} ({h.Value.String}; regex={h.Value.ValidatingRegex}) at idx {h.Begin} of message string '{str}'"); + Config.Log.Info($"Triggered content filter #{h.Value.Id} ({h.Value.String}; regex={h.Value.ValidatingRegex}) at idx {h.Begin} of string '{str}'"); return !h.Value.Actions.HasFlag(FilterAction.IssueWarning); } return true; }); - - if (result is null && ctx == FilterContext.Chat) - { - str = str.StripInvisibleAndDiacritics(); - matcher?.ParseText(str, h => - { - if (string.IsNullOrEmpty(h.Value.ValidatingRegex) || Regex.IsMatch(str, h.Value.ValidatingRegex, RegexOptions.IgnoreCase | RegexOptions.Multiline)) - { - result = h.Value; - Config.Log.Info($"Triggered content filter #{h.Value.Id} ({h.Value.String}; regex={h.Value.ValidatingRegex}) at idx {h.Begin} of string '{str}'"); - return !h.Value.Actions.HasFlag(FilterAction.IssueWarning); - } - return true; - }); - } - - return Task.FromResult(result); } - public static void RebuildMatcher() - { - var newFilters = new Dictionary?>(); - using var db = new BotDb(); - foreach (FilterContext ctx in Enum.GetValues(typeof(FilterContext))) - { - var triggerList = db.Piracystring.Where(ps => ps.Disabled == false && ps.Context.HasFlag(ctx)).AsNoTracking() - .AsEnumerable() - .Concat(db.SuspiciousString.AsNoTracking().AsEnumerable().Select(ss => new Piracystring - { - String = ss.String, - Actions = FilterAction.RemoveContent, // | FilterAction.IssueWarning | FilterAction.SendMessage, - Context = FilterContext.Log | FilterContext.Chat, - CustomMessage = "Please follow the rules and dump your own copy of game yourself. You **can not download** game files from the internet. Repeated offence may result in a ban.", - }) - ).ToList(); + return Task.FromResult(result); + } - if (triggerList.Count == 0) - newFilters[ctx] = null; - else + public static void RebuildMatcher() + { + var newFilters = new Dictionary?>(); + using var db = new BotDb(); + foreach (FilterContext ctx in Enum.GetValues(typeof(FilterContext))) + { + var triggerList = db.Piracystring.Where(ps => ps.Disabled == false && ps.Context.HasFlag(ctx)).AsNoTracking() + .AsEnumerable() + .Concat(db.SuspiciousString.AsNoTracking().AsEnumerable().Select(ss => new Piracystring + { + String = ss.String, + Actions = FilterAction.RemoveContent, // | FilterAction.IssueWarning | FilterAction.SendMessage, + Context = FilterContext.Log | FilterContext.Chat, + CustomMessage = "Please follow the rules and dump your own copy of game yourself. You **can not download** game files from the internet. Repeated offence may result in a ban.", + }) + ).ToList(); + + if (triggerList.Count == 0) + newFilters[ctx] = null; + else + { + try { - try - { - newFilters[ctx] = new(triggerList.ToDictionary(s => s.String, s => s), true); - } - catch (ArgumentException) - { - var duplicate = ( - from ps in triggerList - group ps by ps.String into g - where g.Count() > 1 - select g.Key - ).ToList(); - Config.Log.Error($"Duplicate triggers defined for Context {ctx}: {string.Join(", ", duplicate)}"); - var triggerDictionary = new Dictionary(); - foreach (var ps in triggerList) - triggerDictionary[ps.String] = ps; - newFilters[ctx] = new(triggerDictionary, true); - } + newFilters[ctx] = new(triggerList.ToDictionary(s => s.String, s => s), true); + } + catch (ArgumentException) + { + var duplicate = ( + from ps in triggerList + group ps by ps.String into g + where g.Count() > 1 + select g.Key + ).ToList(); + Config.Log.Error($"Duplicate triggers defined for Context {ctx}: {string.Join(", ", duplicate)}"); + var triggerDictionary = new Dictionary(); + foreach (var ps in triggerList) + triggerDictionary[ps.String] = ps; + newFilters[ctx] = new(triggerDictionary, true); } } - filters = newFilters; } + filters = newFilters; + } - public static async Task IsClean(DiscordClient client, DiscordMessage message) - { - if (message.Channel.IsPrivate) - return true; + public static async Task IsClean(DiscordClient client, DiscordMessage message) + { + if (message.Channel.IsPrivate) + return true; - /* - if (message.Author.IsBotSafeCheck()) - return true; - */ + /* + if (message.Author.IsBotSafeCheck()) + return true; + */ - if (message.Author.IsCurrent) - return true; + if (message.Author.IsCurrent) + return true; - var suppressActions = (FilterAction)0; + var suppressActions = (FilterAction)0; #if !DEBUG if (message.Author.IsWhitelisted(client, message.Channel.Guild)) { @@ -131,140 +131,139 @@ namespace CompatBot.Database.Providers } #endif - if (string.IsNullOrEmpty(message.Content)) - return true; + if (string.IsNullOrEmpty(message.Content)) + return true; - var trigger = await FindTriggerAsync(FilterContext.Chat, message.Content).ConfigureAwait(false); - if (trigger == null) - return true; + var trigger = await FindTriggerAsync(FilterContext.Chat, message.Content).ConfigureAwait(false); + if (trigger == null) + return true; - await PerformFilterActions(client, message, trigger, suppressActions).ConfigureAwait(false); - return (trigger.Actions & ~suppressActions & (FilterAction.IssueWarning | FilterAction.RemoveContent)) == 0; - } + await PerformFilterActions(client, message, trigger, suppressActions).ConfigureAwait(false); + return (trigger.Actions & ~suppressActions & (FilterAction.IssueWarning | FilterAction.RemoveContent)) == 0; + } - public static async Task PerformFilterActions(DiscordClient client, DiscordMessage message, Piracystring trigger, FilterAction ignoreFlags = 0, string? triggerContext = null, string? infraction = null, string? warningReason = null) + public static async Task PerformFilterActions(DiscordClient client, DiscordMessage message, Piracystring trigger, FilterAction ignoreFlags = 0, string? triggerContext = null, string? infraction = null, string? warningReason = null) + { + var severity = ReportSeverity.Low; + var completedActions = new List(); + if (trigger.Actions.HasFlag(FilterAction.RemoveContent) && !ignoreFlags.HasFlag(FilterAction.RemoveContent)) { - var severity = ReportSeverity.Low; - var completedActions = new List(); - if (trigger.Actions.HasFlag(FilterAction.RemoveContent) && !ignoreFlags.HasFlag(FilterAction.RemoveContent)) - { - try - { - DeletedMessagesMonitor.RemovedByBotCache.Set(message.Id, true, DeletedMessagesMonitor.CacheRetainTime); - await message.Channel.DeleteMessageAsync(message, $"Removed according to filter '{trigger}'").ConfigureAwait(false); - completedActions.Add(FilterAction.RemoveContent); - } - catch (Exception e) - { - Config.Log.Warn(e); - severity = ReportSeverity.High; - } - try - { - var author = client.GetMember(message.Author); - var username = author?.GetMentionWithNickname() ?? message.Author.GetUsernameWithNickname(client); - Config.Log.Debug($"Removed message from {username} in #{message.Channel.Name}: {message.Content}"); - } - catch (Exception e) - { - Config.Log.Warn(e); - } - } - - if (trigger.Actions.HasFlag(FilterAction.SendMessage) && !ignoreFlags.HasFlag(FilterAction.SendMessage)) - { - try - { - ResponseAntispamCache.TryGetValue(message.Author.Id, out int counter); - if (counter < 3) - { - - var msgContent = trigger.CustomMessage; - if (string.IsNullOrEmpty(msgContent)) - { - var rules = await client.GetChannelAsync(Config.BotRulesChannelId).ConfigureAwait(false); - msgContent = $"Please follow the {rules.Mention} and do not post/discuss anything piracy-related on this server.\nYou always **must** dump your own copy of the game yourself. You **can not** download game files from the internet.\nRepeated offence may result in a ban."; - } - await message.Channel.SendMessageAsync($"{message.Author.Mention} {msgContent}").ConfigureAwait(false); - } - ResponseAntispamCache.Set(message.Author.Id, counter + 1, CacheTime); - completedActions.Add(FilterAction.SendMessage); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Failed to send message in #{message.Channel.Name}"); - } - } - - if (trigger.Actions.HasFlag(FilterAction.IssueWarning) && !ignoreFlags.HasFlag(FilterAction.IssueWarning)) - { - try - { - await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, warningReason ?? "Mention of piracy", message.Content.Sanitize()).ConfigureAwait(false); - completedActions.Add(FilterAction.IssueWarning); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Couldn't issue warning in #{message.Channel.Name}"); - } - } - - if (trigger.Actions.HasFlag(FilterAction.ShowExplain) - && !ignoreFlags.HasFlag(FilterAction.ShowExplain) - && !string.IsNullOrEmpty(trigger.ExplainTerm)) - { - var result = await Explain.LookupTerm(trigger.ExplainTerm).ConfigureAwait(false); - await Explain.SendExplanation(result, trigger.ExplainTerm, message, true).ConfigureAwait(false); - } - - if (trigger.Actions.HasFlag(FilterAction.Kick) - && !ignoreFlags.HasFlag(FilterAction.Kick)) - { - try - { - if (client.GetMember(message.Channel.Guild, message.Author) is DiscordMember mem - && !mem.Roles.Any()) - { - await mem.RemoveAsync("Filter action for trigger " + trigger.String).ConfigureAwait(false); - completedActions.Add(FilterAction.Kick); - } - } - catch (Exception e) - { - Config.Log.Warn(e, $"Couldn't kick user from server"); - } - } - - var actionList = ""; - foreach (FilterAction fa in Enum.GetValues(typeof(FilterAction))) - { - if (trigger.Actions.HasFlag(fa) && !ignoreFlags.HasFlag(fa)) - actionList += (completedActions.Contains(fa) ? "✅" : "❌") + " " + fa + ' '; - } - try { - ReportAntispamCache.TryGetValue(message.Author.Id, out int counter); - if (!trigger.Actions.HasFlag(FilterAction.MuteModQueue) && !ignoreFlags.HasFlag(FilterAction.MuteModQueue) && counter < 3) + DeletedMessagesMonitor.RemovedByBotCache.Set(message.Id, true, DeletedMessagesMonitor.CacheRetainTime); + await message.Channel.DeleteMessageAsync(message, $"Removed according to filter '{trigger}'").ConfigureAwait(false); + completedActions.Add(FilterAction.RemoveContent); + } + catch (Exception e) + { + Config.Log.Warn(e); + severity = ReportSeverity.High; + } + try + { + var author = client.GetMember(message.Author); + var username = author?.GetMentionWithNickname() ?? message.Author.GetUsernameWithNickname(client); + Config.Log.Debug($"Removed message from {username} in #{message.Channel.Name}: {message.Content}"); + } + catch (Exception e) + { + Config.Log.Warn(e); + } + } + + if (trigger.Actions.HasFlag(FilterAction.SendMessage) && !ignoreFlags.HasFlag(FilterAction.SendMessage)) + { + try + { + ResponseAntispamCache.TryGetValue(message.Author.Id, out int counter); + if (counter < 3) { - var context = triggerContext ?? message.Content; - var matchedOn = GetMatchedScope(trigger, context); - await client.ReportAsync(infraction ?? "🤬 Content filter hit", message, trigger.String, matchedOn, trigger.Id, context, severity, actionList).ConfigureAwait(false); - ReportAntispamCache.Set(message.Author.Id, counter + 1, CacheTime); + + var msgContent = trigger.CustomMessage; + if (string.IsNullOrEmpty(msgContent)) + { + var rules = await client.GetChannelAsync(Config.BotRulesChannelId).ConfigureAwait(false); + msgContent = $"Please follow the {rules.Mention} and do not post/discuss anything piracy-related on this server.\nYou always **must** dump your own copy of the game yourself. You **can not** download game files from the internet.\nRepeated offence may result in a ban."; + } + await message.Channel.SendMessageAsync($"{message.Author.Mention} {msgContent}").ConfigureAwait(false); + } + ResponseAntispamCache.Set(message.Author.Id, counter + 1, CacheTime); + completedActions.Add(FilterAction.SendMessage); + } + catch (Exception e) + { + Config.Log.Warn(e, $"Failed to send message in #{message.Channel.Name}"); + } + } + + if (trigger.Actions.HasFlag(FilterAction.IssueWarning) && !ignoreFlags.HasFlag(FilterAction.IssueWarning)) + { + try + { + await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, warningReason ?? "Mention of piracy", message.Content.Sanitize()).ConfigureAwait(false); + completedActions.Add(FilterAction.IssueWarning); + } + catch (Exception e) + { + Config.Log.Warn(e, $"Couldn't issue warning in #{message.Channel.Name}"); + } + } + + if (trigger.Actions.HasFlag(FilterAction.ShowExplain) + && !ignoreFlags.HasFlag(FilterAction.ShowExplain) + && !string.IsNullOrEmpty(trigger.ExplainTerm)) + { + var result = await Explain.LookupTerm(trigger.ExplainTerm).ConfigureAwait(false); + await Explain.SendExplanation(result, trigger.ExplainTerm, message, true).ConfigureAwait(false); + } + + if (trigger.Actions.HasFlag(FilterAction.Kick) + && !ignoreFlags.HasFlag(FilterAction.Kick)) + { + try + { + if (client.GetMember(message.Channel.Guild, message.Author) is DiscordMember mem + && !mem.Roles.Any()) + { + await mem.RemoveAsync("Filter action for trigger " + trigger.String).ConfigureAwait(false); + completedActions.Add(FilterAction.Kick); } } catch (Exception e) { - Config.Log.Error(e, "Failed to report content filter hit"); + Config.Log.Warn(e, $"Couldn't kick user from server"); } } - public static string? GetMatchedScope(Piracystring trigger, string? context) - => context is { Length: >0 } - && trigger.ValidatingRegex is { Length: >0 } pattern - && Regex.Match(context, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline) is { Success: true } m - && m.Groups.Count > 0 - ? m.Groups[0].Value.Trim(256) - : null; + var actionList = ""; + foreach (FilterAction fa in Enum.GetValues(typeof(FilterAction))) + { + if (trigger.Actions.HasFlag(fa) && !ignoreFlags.HasFlag(fa)) + actionList += (completedActions.Contains(fa) ? "✅" : "❌") + " " + fa + ' '; + } + + try + { + ReportAntispamCache.TryGetValue(message.Author.Id, out int counter); + if (!trigger.Actions.HasFlag(FilterAction.MuteModQueue) && !ignoreFlags.HasFlag(FilterAction.MuteModQueue) && counter < 3) + { + var context = triggerContext ?? message.Content; + var matchedOn = GetMatchedScope(trigger, context); + await client.ReportAsync(infraction ?? "🤬 Content filter hit", message, trigger.String, matchedOn, trigger.Id, context, severity, actionList).ConfigureAwait(false); + ReportAntispamCache.Set(message.Author.Id, counter + 1, CacheTime); + } + } + catch (Exception e) + { + Config.Log.Error(e, "Failed to report content filter hit"); + } } + + public static string? GetMatchedScope(Piracystring trigger, string? context) + => context is { Length: >0 } + && trigger.ValidatingRegex is { Length: >0 } pattern + && Regex.Match(context, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline) is { Success: true } m + && m.Groups.Count > 0 + ? m.Groups[0].Value.Trim(256) + : null; } \ No newline at end of file diff --git a/CompatBot/Database/Providers/DisabledCommandsProvider.cs b/CompatBot/Database/Providers/DisabledCommandsProvider.cs index 487cbc8d..ff44262f 100644 --- a/CompatBot/Database/Providers/DisabledCommandsProvider.cs +++ b/CompatBot/Database/Providers/DisabledCommandsProvider.cs @@ -2,59 +2,58 @@ using System.Collections.Generic; using System.Linq; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +internal static class DisabledCommandsProvider { - internal static class DisabledCommandsProvider + private static readonly HashSet DisabledCommands = new(StringComparer.InvariantCultureIgnoreCase); + + static DisabledCommandsProvider() { - private static readonly HashSet DisabledCommands = new(StringComparer.InvariantCultureIgnoreCase); - - static DisabledCommandsProvider() + lock (DisabledCommands) { - lock (DisabledCommands) - { - using var db = new BotDb(); - foreach (var cmd in db.DisabledCommands.ToList()) - DisabledCommands.Add(cmd.Command); - } - } - - public static HashSet Get() => DisabledCommands; - - public static void Disable(string command) - { - lock (DisabledCommands) - if (DisabledCommands.Add(command)) - { - using var db = new BotDb(); - db.DisabledCommands.Add(new DisabledCommand {Command = command}); - db.SaveChanges(); - } - } - - public static void Enable(string command) - { - lock (DisabledCommands) - if (DisabledCommands.Remove(command)) - { - using var db = new BotDb(); - var cmd = db.DisabledCommands.FirstOrDefault(c => c.Command == command); - if (cmd == null) - return; - - db.DisabledCommands.Remove(cmd); - db.SaveChanges(); - } - } - - public static void Clear() - { - lock (DisabledCommands) - { - DisabledCommands.Clear(); - using var db = new BotDb(); - db.DisabledCommands.RemoveRange(db.DisabledCommands); - db.SaveChanges(); - } + using var db = new BotDb(); + foreach (var cmd in db.DisabledCommands.ToList()) + DisabledCommands.Add(cmd.Command); } } -} + + public static HashSet Get() => DisabledCommands; + + public static void Disable(string command) + { + lock (DisabledCommands) + if (DisabledCommands.Add(command)) + { + using var db = new BotDb(); + db.DisabledCommands.Add(new DisabledCommand {Command = command}); + db.SaveChanges(); + } + } + + public static void Enable(string command) + { + lock (DisabledCommands) + if (DisabledCommands.Remove(command)) + { + using var db = new BotDb(); + var cmd = db.DisabledCommands.FirstOrDefault(c => c.Command == command); + if (cmd == null) + return; + + db.DisabledCommands.Remove(cmd); + db.SaveChanges(); + } + } + + public static void Clear() + { + lock (DisabledCommands) + { + DisabledCommands.Clear(); + using var db = new BotDb(); + db.DisabledCommands.RemoveRange(db.DisabledCommands); + db.SaveChanges(); + } + } +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/InviteWhitelistProvider.cs b/CompatBot/Database/Providers/InviteWhitelistProvider.cs index 618d81f2..a38c9fd9 100644 --- a/CompatBot/Database/Providers/InviteWhitelistProvider.cs +++ b/CompatBot/Database/Providers/InviteWhitelistProvider.cs @@ -6,95 +6,94 @@ using DSharpPlus.Entities; using DSharpPlus.Exceptions; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +internal static class InviteWhitelistProvider { - internal static class InviteWhitelistProvider + public static bool IsWhitelisted(ulong guildId) { - public static bool IsWhitelisted(ulong guildId) - { - using var db = new BotDb(); - return db.WhitelistedInvites.Any(i => i.GuildId == guildId); - } + using var db = new BotDb(); + return db.WhitelistedInvites.Any(i => i.GuildId == guildId); + } - public static async Task IsWhitelistedAsync(DiscordInvite invite) - { - var code = string.IsNullOrWhiteSpace(invite.Code) ? null : invite.Code; - var name = string.IsNullOrWhiteSpace(invite.Guild.Name) ? null : invite.Guild.Name; - await using var db = new BotDb(); - var whitelistedInvite = await db.WhitelistedInvites.FirstOrDefaultAsync(i => i.GuildId == invite.Guild.Id); - if (whitelistedInvite == null) - return false; + public static async Task IsWhitelistedAsync(DiscordInvite invite) + { + var code = string.IsNullOrWhiteSpace(invite.Code) ? null : invite.Code; + var name = string.IsNullOrWhiteSpace(invite.Guild.Name) ? null : invite.Guild.Name; + await using var db = new BotDb(); + var whitelistedInvite = await db.WhitelistedInvites.FirstOrDefaultAsync(i => i.GuildId == invite.Guild.Id); + if (whitelistedInvite == null) + return false; - if (name != null && name != whitelistedInvite.Name) - whitelistedInvite.Name = invite.Guild.Name; - if (string.IsNullOrEmpty(whitelistedInvite.InviteCode) && code != null) - whitelistedInvite.InviteCode = code; - await db.SaveChangesAsync().ConfigureAwait(false); - return true; - } + if (name != null && name != whitelistedInvite.Name) + whitelistedInvite.Name = invite.Guild.Name; + if (string.IsNullOrEmpty(whitelistedInvite.InviteCode) && code != null) + whitelistedInvite.InviteCode = code; + await db.SaveChangesAsync().ConfigureAwait(false); + return true; + } - public static async Task AddAsync(DiscordInvite invite) - { - if (await IsWhitelistedAsync(invite).ConfigureAwait(false)) - return false; + public static async Task AddAsync(DiscordInvite invite) + { + if (await IsWhitelistedAsync(invite).ConfigureAwait(false)) + return false; - var code = invite.IsRevoked || string.IsNullOrWhiteSpace(invite.Code) ? null : invite.Code; - var name = string.IsNullOrWhiteSpace(invite.Guild.Name) ? null : invite.Guild.Name; - await using var db = new BotDb(); - await db.WhitelistedInvites.AddAsync(new WhitelistedInvite { GuildId = invite.Guild.Id, Name = name, InviteCode = code }).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - return true; - } + var code = invite.IsRevoked || string.IsNullOrWhiteSpace(invite.Code) ? null : invite.Code; + var name = string.IsNullOrWhiteSpace(invite.Guild.Name) ? null : invite.Guild.Name; + await using var db = new BotDb(); + await db.WhitelistedInvites.AddAsync(new WhitelistedInvite { GuildId = invite.Guild.Id, Name = name, InviteCode = code }).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + return true; + } - public static async Task AddAsync(ulong guildId) - { - if (IsWhitelisted(guildId)) - return false; + public static async Task AddAsync(ulong guildId) + { + if (IsWhitelisted(guildId)) + return false; - await using var db = new BotDb(); - await db.WhitelistedInvites.AddAsync(new WhitelistedInvite {GuildId = guildId}).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - return true; - } + await using var db = new BotDb(); + await db.WhitelistedInvites.AddAsync(new WhitelistedInvite {GuildId = guildId}).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + return true; + } - public static async Task RemoveAsync(int id) + public static async Task RemoveAsync(int id) + { + await using var db = new BotDb(); + var dbItem = await db.WhitelistedInvites.FirstOrDefaultAsync(i => i.Id == id).ConfigureAwait(false); + if (dbItem == null) + return false; + + db.WhitelistedInvites.Remove(dbItem); + await db.SaveChangesAsync().ConfigureAwait(false); + return true; + } + + public static async Task CleanupAsync(DiscordClient client) + { + while (!Config.Cts.IsCancellationRequested) { await using var db = new BotDb(); - var dbItem = await db.WhitelistedInvites.FirstOrDefaultAsync(i => i.Id == id).ConfigureAwait(false); - if (dbItem == null) - return false; - - db.WhitelistedInvites.Remove(dbItem); - await db.SaveChangesAsync().ConfigureAwait(false); - return true; - } - - public static async Task CleanupAsync(DiscordClient client) - { - while (!Config.Cts.IsCancellationRequested) + foreach (var invite in db.WhitelistedInvites.Where(i => i.InviteCode != null)) { - await using var db = new BotDb(); - foreach (var invite in db.WhitelistedInvites.Where(i => i.InviteCode != null)) + try { - try - { - var result = await client.GetInviteByCodeAsync(invite.InviteCode).ConfigureAwait(false); - if (result?.IsRevoked == true) - invite.InviteCode = null; - } - catch (NotFoundException) - { + var result = await client.GetInviteByCodeAsync(invite.InviteCode).ConfigureAwait(false); + if (result?.IsRevoked == true) invite.InviteCode = null; - Config.Log.Info($"Removed invite code {invite.InviteCode} for server {invite.Name}"); - } - catch (Exception e) - { - Config.Log.Debug(e); - } } - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - await Task.Delay(TimeSpan.FromHours(1), Config.Cts.Token).ConfigureAwait(false); + catch (NotFoundException) + { + invite.InviteCode = null; + Config.Log.Info($"Removed invite code {invite.InviteCode} for server {invite.Name}"); + } + catch (Exception e) + { + Config.Log.Debug(e); + } } + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromHours(1), Config.Cts.Token).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/CompatBot/Database/Providers/ModProvider.cs b/CompatBot/Database/Providers/ModProvider.cs index ae5a890e..baaa8f6b 100644 --- a/CompatBot/Database/Providers/ModProvider.cs +++ b/CompatBot/Database/Providers/ModProvider.cs @@ -5,115 +5,114 @@ using System.Threading.Tasks; using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +internal static class ModProvider { - internal static class ModProvider + private static readonly Dictionary Moderators; + private static readonly BotDb Db = new(); + public static ReadOnlyDictionary Mods => new(Moderators); + + static ModProvider() { - private static readonly Dictionary Moderators; - private static readonly BotDb Db = new(); - public static ReadOnlyDictionary Mods => new(Moderators); + Moderators = Db.Moderator.AsNoTracking().ToDictionary(m => m.DiscordId, m => m); + } - static ModProvider() - { - Moderators = Db.Moderator.AsNoTracking().ToDictionary(m => m.DiscordId, m => m); - } + public static bool IsMod(ulong userId) => Moderators.ContainsKey(userId); - public static bool IsMod(ulong userId) => Moderators.ContainsKey(userId); + public static bool IsSudoer(ulong userId) => Moderators.TryGetValue(userId, out var mod) && mod.Sudoer; - public static bool IsSudoer(ulong userId) => Moderators.TryGetValue(userId, out var mod) && mod.Sudoer; + public static async Task AddAsync(ulong userId) + { + if (IsMod(userId)) + return false; - public static async Task AddAsync(ulong userId) + var newMod = new Moderator {DiscordId = userId}; + await Db.Moderator.AddAsync(newMod).ConfigureAwait(false); + await Db.SaveChangesAsync().ConfigureAwait(false); + lock (Moderators) { if (IsMod(userId)) return false; + Moderators[userId] = newMod; + } + return true; + } - var newMod = new Moderator {DiscordId = userId}; - await Db.Moderator.AddAsync(newMod).ConfigureAwait(false); + public static async Task RemoveAsync(ulong userId) + { + if (!Moderators.ContainsKey(userId)) + return false; + + var mod = await Db.Moderator.FirstOrDefaultAsync(m => m.DiscordId == userId).ConfigureAwait(false); + if (mod is not null) + { + Db.Moderator.Remove(mod); await Db.SaveChangesAsync().ConfigureAwait(false); - lock (Moderators) - { - if (IsMod(userId)) - return false; - Moderators[userId] = newMod; - } - return true; } - - public static async Task RemoveAsync(ulong userId) + lock (Moderators) { - if (!Moderators.ContainsKey(userId)) + if (IsMod(userId)) + Moderators.Remove(userId); + else return false; - - var mod = await Db.Moderator.FirstOrDefaultAsync(m => m.DiscordId == userId).ConfigureAwait(false); - if (mod is not null) - { - Db.Moderator.Remove(mod); - await Db.SaveChangesAsync().ConfigureAwait(false); - } - lock (Moderators) - { - if (IsMod(userId)) - Moderators.Remove(userId); - else - return false; - } - return true; } + return true; + } - public static async Task MakeSudoerAsync(ulong userId) + public static async Task MakeSudoerAsync(ulong userId) + { + if (!Moderators.TryGetValue(userId, out var mod) || mod.Sudoer) + return false; + + var dbMod = await Db.Moderator.FirstOrDefaultAsync(m => m.DiscordId == userId).ConfigureAwait(false); + if (dbMod is not null) { - if (!Moderators.TryGetValue(userId, out var mod) || mod.Sudoer) - return false; - - var dbMod = await Db.Moderator.FirstOrDefaultAsync(m => m.DiscordId == userId).ConfigureAwait(false); - if (dbMod is not null) - { - dbMod.Sudoer = true; - await Db.SaveChangesAsync().ConfigureAwait(false); - } - mod.Sudoer = true; - return true; - } - - public static async Task UnmakeSudoerAsync(ulong userId) - { - if (!Moderators.TryGetValue(userId, out var mod) || !mod.Sudoer) - return false; - - var dbMod = await Db.Moderator.FirstOrDefaultAsync(m => m.DiscordId == userId).ConfigureAwait(false); - if (dbMod is not null) - { - dbMod.Sudoer = false; - await Db.SaveChangesAsync().ConfigureAwait(false); - } - mod.Sudoer = false; + dbMod.Sudoer = true; await Db.SaveChangesAsync().ConfigureAwait(false); - return true; } + mod.Sudoer = true; + return true; + } - public static async Task SyncRolesAsync(DiscordGuild guild) + public static async Task UnmakeSudoerAsync(ulong userId) + { + if (!Moderators.TryGetValue(userId, out var mod) || !mod.Sudoer) + return false; + + var dbMod = await Db.Moderator.FirstOrDefaultAsync(m => m.DiscordId == userId).ConfigureAwait(false); + if (dbMod is not null) { - Config.Log.Debug("Syncing moderator list to the sudoer role"); - var modRoleList = guild.Roles.Where(kvp => kvp.Value.Name.Equals("Moderator")).ToList(); - if (modRoleList.Count == 0) - return; + dbMod.Sudoer = false; + await Db.SaveChangesAsync().ConfigureAwait(false); + } + mod.Sudoer = false; + await Db.SaveChangesAsync().ConfigureAwait(false); + return true; + } - var modRole = modRoleList.First().Value; - var members = await guild.GetAllMembersAsync().ConfigureAwait(false); - var guildMods = members.Where(m => m.Roles.Any(r => r.Id == modRole.Id) && !m.IsBot && !m.IsCurrent).ToList(); - foreach (var mod in guildMods) + public static async Task SyncRolesAsync(DiscordGuild guild) + { + Config.Log.Debug("Syncing moderator list to the sudoer role"); + var modRoleList = guild.Roles.Where(kvp => kvp.Value.Name.Equals("Moderator")).ToList(); + if (modRoleList.Count == 0) + return; + + var modRole = modRoleList.First().Value; + var members = await guild.GetAllMembersAsync().ConfigureAwait(false); + var guildMods = members.Where(m => m.Roles.Any(r => r.Id == modRole.Id) && !m.IsBot && !m.IsCurrent).ToList(); + foreach (var mod in guildMods) + { + if (!IsMod(mod.Id)) { - if (!IsMod(mod.Id)) - { - Config.Log.Debug($"Making {mod.Username}#{mod.Discriminator} a bot mod"); - await AddAsync(mod.Id).ConfigureAwait(false); - } - if (!IsSudoer(mod.Id)) - { - Config.Log.Debug($"Making {mod.Username}#{mod.Discriminator} a bot sudoer"); - await MakeSudoerAsync(mod.Id).ConfigureAwait(false); - } + Config.Log.Debug($"Making {mod.Username}#{mod.Discriminator} a bot mod"); + await AddAsync(mod.Id).ConfigureAwait(false); + } + if (!IsSudoer(mod.Id)) + { + Config.Log.Debug($"Making {mod.Username}#{mod.Discriminator} a bot sudoer"); + await MakeSudoerAsync(mod.Id).ConfigureAwait(false); } } } -} +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/ScrapeStateProvider.cs b/CompatBot/Database/Providers/ScrapeStateProvider.cs index ac6fb5d3..cf166868 100644 --- a/CompatBot/Database/Providers/ScrapeStateProvider.cs +++ b/CompatBot/Database/Providers/ScrapeStateProvider.cs @@ -3,66 +3,65 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +internal static class ScrapeStateProvider { - internal static class ScrapeStateProvider + private static readonly TimeSpan CheckInterval = TimeSpan.FromDays(365); + + public static bool IsFresh(long timestamp) + => IsFresh(new DateTime(timestamp, DateTimeKind.Utc)); + + public static bool IsFresh(DateTime timestamp) + => timestamp.Add(CheckInterval) > DateTime.UtcNow; + + public static bool IsFresh(string locale, string? containerId = null) { - private static readonly TimeSpan CheckInterval = TimeSpan.FromDays(365); - - public static bool IsFresh(long timestamp) - => IsFresh(new DateTime(timestamp, DateTimeKind.Utc)); - - public static bool IsFresh(DateTime timestamp) - => timestamp.Add(CheckInterval) > DateTime.UtcNow; - - public static bool IsFresh(string locale, string? containerId = null) - { - var id = GetId(locale, containerId); - using var db = new ThumbnailDb(); - var timestamp = string.IsNullOrEmpty(id) ? db.State.OrderBy(s => s.Timestamp).FirstOrDefault() : db.State.FirstOrDefault(s => s.Locale == id); - if (timestamp?.Timestamp is long checkDate && checkDate > 0) - return IsFresh(new DateTime(checkDate, DateTimeKind.Utc)); - return false; - } - - public static bool IsFresh(string locale, DateTime dataTimestamp) - { - using var db = new ThumbnailDb(); - var timestamp = string.IsNullOrEmpty(locale) ? db.State.OrderBy(s => s.Timestamp).FirstOrDefault() : db.State.FirstOrDefault(s => s.Locale == locale); - if (timestamp?.Timestamp is long checkDate && checkDate > 0) - return new DateTime(checkDate, DateTimeKind.Utc) > dataTimestamp; - return false; - } - - public static async Task SetLastRunTimestampAsync(string locale, string? containerId = null) - { - if (string.IsNullOrEmpty(locale)) - throw new ArgumentException("Locale is mandatory", nameof(locale)); - - var id = GetId(locale, containerId); - await using var db = new ThumbnailDb(); - var timestamp = db.State.FirstOrDefault(s => s.Locale == id); - if (timestamp == null) - await db.State.AddAsync(new State {Locale = id, Timestamp = DateTime.UtcNow.Ticks}).ConfigureAwait(false); - else - timestamp.Timestamp = DateTime.UtcNow.Ticks; - await db.SaveChangesAsync().ConfigureAwait(false); - } - - public static async Task CleanAsync(CancellationToken cancellationToken) - { - await using var db = new ThumbnailDb(); - var latestTimestamp = db.State.OrderByDescending(s => s.Timestamp).FirstOrDefault()?.Timestamp; - if (!latestTimestamp.HasValue) - return; - - var cutOff = new DateTime(latestTimestamp.Value, DateTimeKind.Utc).Add(-CheckInterval); - var oldItems = db.State.Where(s => s.Timestamp < cutOff.Ticks); - db.State.RemoveRange(oldItems); - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - - private static string GetId(string locale, string? containerId) - => string.IsNullOrEmpty(containerId) ? locale : $"{locale} - {containerId}"; + var id = GetId(locale, containerId); + using var db = new ThumbnailDb(); + var timestamp = string.IsNullOrEmpty(id) ? db.State.OrderBy(s => s.Timestamp).FirstOrDefault() : db.State.FirstOrDefault(s => s.Locale == id); + if (timestamp?.Timestamp is long checkDate && checkDate > 0) + return IsFresh(new DateTime(checkDate, DateTimeKind.Utc)); + return false; } -} + + public static bool IsFresh(string locale, DateTime dataTimestamp) + { + using var db = new ThumbnailDb(); + var timestamp = string.IsNullOrEmpty(locale) ? db.State.OrderBy(s => s.Timestamp).FirstOrDefault() : db.State.FirstOrDefault(s => s.Locale == locale); + if (timestamp?.Timestamp is long checkDate && checkDate > 0) + return new DateTime(checkDate, DateTimeKind.Utc) > dataTimestamp; + return false; + } + + public static async Task SetLastRunTimestampAsync(string locale, string? containerId = null) + { + if (string.IsNullOrEmpty(locale)) + throw new ArgumentException("Locale is mandatory", nameof(locale)); + + var id = GetId(locale, containerId); + await using var db = new ThumbnailDb(); + var timestamp = db.State.FirstOrDefault(s => s.Locale == id); + if (timestamp == null) + await db.State.AddAsync(new State {Locale = id, Timestamp = DateTime.UtcNow.Ticks}).ConfigureAwait(false); + else + timestamp.Timestamp = DateTime.UtcNow.Ticks; + await db.SaveChangesAsync().ConfigureAwait(false); + } + + public static async Task CleanAsync(CancellationToken cancellationToken) + { + await using var db = new ThumbnailDb(); + var latestTimestamp = db.State.OrderByDescending(s => s.Timestamp).FirstOrDefault()?.Timestamp; + if (!latestTimestamp.HasValue) + return; + + var cutOff = new DateTime(latestTimestamp.Value, DateTimeKind.Utc).Add(-CheckInterval); + var oldItems = db.State.Where(s => s.Timestamp < cutOff.Ticks); + db.State.RemoveRange(oldItems); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private static string GetId(string locale, string? containerId) + => string.IsNullOrEmpty(containerId) ? locale : $"{locale} - {containerId}"; +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/SqlConfiguration.cs b/CompatBot/Database/Providers/SqlConfiguration.cs index 316c1d16..41e4f5cd 100644 --- a/CompatBot/Database/Providers/SqlConfiguration.cs +++ b/CompatBot/Database/Providers/SqlConfiguration.cs @@ -2,23 +2,22 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Database.Providers -{ - internal static class SqlConfiguration - { - internal const string ConfigVarPrefix = "ENV-"; +namespace CompatBot.Database.Providers; - public static async Task RestoreAsync() +internal static class SqlConfiguration +{ + internal const string ConfigVarPrefix = "ENV-"; + + public static async Task RestoreAsync() + { + await using var db = new BotDb(); + var setVars = await db.BotState.AsNoTracking().Where(v => v.Key.StartsWith(ConfigVarPrefix)).ToListAsync().ConfigureAwait(false); + if (setVars.Any()) { - await using var db = new BotDb(); - var setVars = await db.BotState.AsNoTracking().Where(v => v.Key.StartsWith(ConfigVarPrefix)).ToListAsync().ConfigureAwait(false); - if (setVars.Any()) - { - foreach (var stateVar in setVars) - if (stateVar.Value is string value) - Config.InMemorySettings[stateVar.Key[ConfigVarPrefix.Length ..]] = value; - Config.RebuildConfiguration(); - } + foreach (var stateVar in setVars) + if (stateVar.Value is string value) + Config.InMemorySettings[stateVar.Key[ConfigVarPrefix.Length ..]] = value; + Config.RebuildConfiguration(); } } -} +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/StatsStorage.cs b/CompatBot/Database/Providers/StatsStorage.cs index 1ee7c9c6..69a917e3 100644 --- a/CompatBot/Database/Providers/StatsStorage.cs +++ b/CompatBot/Database/Providers/StatsStorage.cs @@ -7,92 +7,91 @@ using CompatBot.Utils; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +internal static class StatsStorage { - internal static class StatsStorage + internal static readonly TimeSpan CacheTime = TimeSpan.FromDays(1); + internal static readonly MemoryCache CmdStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) }); + internal static readonly MemoryCache ExplainStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) }); + internal static readonly MemoryCache GameStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) }); + + private static readonly SemaphoreSlim Barrier = new(1, 1); + private static readonly (string name, MemoryCache cache)[] AllCaches = { - internal static readonly TimeSpan CacheTime = TimeSpan.FromDays(1); - internal static readonly MemoryCache CmdStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) }); - internal static readonly MemoryCache ExplainStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) }); - internal static readonly MemoryCache GameStatCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromDays(1) }); + (nameof(CmdStatCache), CmdStatCache), + (nameof(ExplainStatCache), ExplainStatCache), + (nameof(GameStatCache), GameStatCache), + }; - private static readonly SemaphoreSlim Barrier = new(1, 1); - private static readonly (string name, MemoryCache cache)[] AllCaches = + public static async Task SaveAsync(bool wait = false) + { + if (await Barrier.WaitAsync(0).ConfigureAwait(false)) { - (nameof(CmdStatCache), CmdStatCache), - (nameof(ExplainStatCache), ExplainStatCache), - (nameof(GameStatCache), GameStatCache), - }; - - public static async Task SaveAsync(bool wait = false) - { - if (await Barrier.WaitAsync(0).ConfigureAwait(false)) + try { - try + Config.Log.Debug("Got stats saving lock"); + await using var db = new BotDb(); + db.Stats.RemoveRange(db.Stats); + await db.SaveChangesAsync().ConfigureAwait(false); + foreach (var (category, cache) in AllCaches) { - Config.Log.Debug("Got stats saving lock"); - await using var db = new BotDb(); - db.Stats.RemoveRange(db.Stats); - await db.SaveChangesAsync().ConfigureAwait(false); - foreach (var (category, cache) in AllCaches) - { - var entries = cache.GetCacheEntries(); - var savedKeys = new HashSet(); - foreach (var (key, value) in entries) - if (savedKeys.Add(key)) - await db.Stats.AddAsync(new Stats - { - Category = category, - Key = key, - Value = (int?)value?.Value ?? 0, - ExpirationTimestamp = value?.AbsoluteExpiration?.ToUniversalTime().Ticks ?? 0 - }).ConfigureAwait(false); - else - Config.Log.Warn($"Somehow there's another '{key}' in the {category} cache"); - } - await db.SaveChangesAsync().ConfigureAwait(false); - } - catch(Exception e) - { - Config.Log.Error(e, "Failed to save user stats"); - } - finally - { - Barrier.Release(); - Config.Log.Debug("Released stats saving lock"); + var entries = cache.GetCacheEntries(); + var savedKeys = new HashSet(); + foreach (var (key, value) in entries) + if (savedKeys.Add(key)) + await db.Stats.AddAsync(new Stats + { + Category = category, + Key = key, + Value = (int?)value?.Value ?? 0, + ExpirationTimestamp = value?.AbsoluteExpiration?.ToUniversalTime().Ticks ?? 0 + }).ConfigureAwait(false); + else + Config.Log.Warn($"Somehow there's another '{key}' in the {category} cache"); } + await db.SaveChangesAsync().ConfigureAwait(false); } - else if (wait) + catch(Exception e) + { + Config.Log.Error(e, "Failed to save user stats"); + } + finally { - await Barrier.WaitAsync().ConfigureAwait(false); Barrier.Release(); + Config.Log.Debug("Released stats saving lock"); } } - - public static async Task RestoreAsync() + else if (wait) { - var now = DateTime.UtcNow; - await using var db = new BotDb(); - foreach (var (category, cache) in AllCaches) - { - var entries = await db.Stats.Where(e => e.Category == category).ToListAsync().ConfigureAwait(false); - foreach (var entry in entries) - { - var time = entry.ExpirationTimestamp.AsUtc(); - if (time > now) - cache.Set(entry.Key, entry.Value, time); - } - } + await Barrier.WaitAsync().ConfigureAwait(false); + Barrier.Release(); } + } - public static async Task BackgroundSaveAsync() + public static async Task RestoreAsync() + { + var now = DateTime.UtcNow; + await using var db = new BotDb(); + foreach (var (category, cache) in AllCaches) { - while (!Config.Cts.IsCancellationRequested) + var entries = await db.Stats.Where(e => e.Category == category).ToListAsync().ConfigureAwait(false); + foreach (var entry in entries) { - await Task.Delay(60 * 60 * 1000, Config.Cts.Token).ConfigureAwait(false); - if (!Config.Cts.IsCancellationRequested) - await SaveAsync().ConfigureAwait(false); + var time = entry.ExpirationTimestamp.AsUtc(); + if (time > now) + cache.Set(entry.Key, entry.Value, time); } } } -} + + public static async Task BackgroundSaveAsync() + { + while (!Config.Cts.IsCancellationRequested) + { + await Task.Delay(60 * 60 * 1000, Config.Cts.Token).ConfigureAwait(false); + if (!Config.Cts.IsCancellationRequested) + await SaveAsync().ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/SyscallInfoProvider.cs b/CompatBot/Database/Providers/SyscallInfoProvider.cs index 52c7effe..3233d4d2 100644 --- a/CompatBot/Database/Providers/SyscallInfoProvider.cs +++ b/CompatBot/Database/Providers/SyscallInfoProvider.cs @@ -6,157 +6,156 @@ using System.Threading.Tasks; using CompatBot.Utils; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +using TSyscallStats = Dictionary>; + +internal static class SyscallInfoProvider { - using TSyscallStats = Dictionary>; + private static readonly SemaphoreSlim Limiter = new(1, 1); - internal static class SyscallInfoProvider + public static async Task SaveAsync(TSyscallStats syscallInfo) { - private static readonly SemaphoreSlim Limiter = new(1, 1); + if (syscallInfo.Count == 0) + return; - public static async Task SaveAsync(TSyscallStats syscallInfo) + if (await Limiter.WaitAsync(1000, Config.Cts.Token)) { - if (syscallInfo.Count == 0) - return; - - if (await Limiter.WaitAsync(1000, Config.Cts.Token)) + try { - try + await using var db = new ThumbnailDb(); + foreach (var productCodeMap in syscallInfo) { - await using var db = new ThumbnailDb(); - foreach (var productCodeMap in syscallInfo) + var product = db.Thumbnail.AsNoTracking().FirstOrDefault(t => t.ProductCode == productCodeMap.Key) + ?? (await db.Thumbnail.AddAsync(new Thumbnail {ProductCode = productCodeMap.Key}).ConfigureAwait(false)).Entity; + if (product.Id == 0) + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + + foreach (var func in productCodeMap.Value) { - var product = db.Thumbnail.AsNoTracking().FirstOrDefault(t => t.ProductCode == productCodeMap.Key) - ?? (await db.Thumbnail.AddAsync(new Thumbnail {ProductCode = productCodeMap.Key}).ConfigureAwait(false)).Entity; - if (product.Id == 0) + var syscall = db.SyscallInfo.AsNoTracking().FirstOrDefault(sci => sci.Function == func.ToUtf8()) + ?? (await db.SyscallInfo.AddAsync(new SyscallInfo {Function = func.ToUtf8() }).ConfigureAwait(false)).Entity; + if (syscall.Id == 0) await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - foreach (var func in productCodeMap.Value) - { - var syscall = db.SyscallInfo.AsNoTracking().FirstOrDefault(sci => sci.Function == func.ToUtf8()) - ?? (await db.SyscallInfo.AddAsync(new SyscallInfo {Function = func.ToUtf8() }).ConfigureAwait(false)).Entity; - if (syscall.Id == 0) - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - - if (!db.SyscallToProductMap.Any(m => m.ProductId == product.Id && m.SyscallInfoId == syscall.Id)) - await db.SyscallToProductMap.AddAsync(new SyscallToProductMap {ProductId = product.Id, SyscallInfoId = syscall.Id}).ConfigureAwait(false); - } - } - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - } - finally - { - Limiter.Release(); - } - } - } - - public static async Task<(int funcs, int links)> FixInvalidFunctionNamesAsync() - { - var syscallStats = new TSyscallStats(); - int funcs, links = 0; - await using var db = new ThumbnailDb(); - var funcsToRemove = new List(0); - try - { - funcsToRemove = db.SyscallInfo.AsEnumerable().Where(sci => sci.Function.Contains('(') || sci.Function.StartsWith('“')).ToList(); - funcs = funcsToRemove.Count; - if (funcs == 0) - return (0, 0); - - foreach (var sci in funcsToRemove.Where(sci => sci.Function.Contains('('))) - { - var productIds = await db.SyscallToProductMap - .AsNoTracking() - .Where(m => m.SyscallInfoId == sci.Id) - .Select(m => m.Product.ProductCode) - .Distinct() - .ToListAsync() - .ConfigureAwait(false); - links += productIds.Count; - foreach (var productId in productIds) - { - if (!syscallStats.TryGetValue(productId, out var smInfo)) - syscallStats[productId] = smInfo = new HashSet(); - smInfo.Add(sci.Function.Split('(', 2)[0]); + if (!db.SyscallToProductMap.Any(m => m.ProductId == product.Id && m.SyscallInfoId == syscall.Id)) + await db.SyscallToProductMap.AddAsync(new SyscallToProductMap {ProductId = product.Id, SyscallInfoId = syscall.Id}).ConfigureAwait(false); } } - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to build fixed syscall mappings"); - throw; - } - - await SaveAsync(syscallStats).ConfigureAwait(false); - if (!await Limiter.WaitAsync(1000, Config.Cts.Token)) - return (funcs, links); - - try - { - db.SyscallInfo.RemoveRange(funcsToRemove); - await db.SaveChangesAsync().ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to remove broken syscall mappings"); - throw; + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); } finally { Limiter.Release(); } - return (funcs, links); - } - - public static async Task<(int funcs, int links)> FixDuplicatesAsync() - { - int funcs = 0, links = 0; - await using var db = new ThumbnailDb(); - var duplicateFunctionNames = await db.SyscallInfo.Where(sci => db.SyscallInfo.Count(isci => isci.Function == sci.Function) > 1).Distinct().ToListAsync().ConfigureAwait(false); - if (duplicateFunctionNames.Count == 0) - return (0, 0); - - if (!await Limiter.WaitAsync(1000, Config.Cts.Token)) - return (funcs, links); - - try - { - foreach (var dupFunc in duplicateFunctionNames) - { - var dups = db.SyscallInfo.Where(sci => sci.Function == dupFunc.Function).ToList(); - if (dups.Count < 2) - continue; - - var mostCommonDup = dups.Select(dup => (dup, count: db.SyscallToProductMap.Count(scm => scm.SyscallInfoId == dup.Id))).OrderByDescending(stat => stat.count).First().dup; - var dupsToRemove = dups.Where(df => df.Id != mostCommonDup.Id).ToList(); - funcs += dupsToRemove.Count; - foreach (var dupToRemove in dupsToRemove) - { - var mappings = db.SyscallToProductMap.Where(scm => scm.SyscallInfoId == dupToRemove.Id).ToList(); - links += mappings.Count; - foreach (var mapping in mappings) - { - if (!db.SyscallToProductMap.Any(scm => scm.ProductId == mapping.ProductId && scm.SyscallInfoId == mostCommonDup.Id)) - await db.SyscallToProductMap.AddAsync(new SyscallToProductMap {ProductId = mapping.ProductId, SyscallInfoId = mostCommonDup.Id}).ConfigureAwait(false); - } - } - await db.SaveChangesAsync().ConfigureAwait(false); - db.SyscallInfo.RemoveRange(dupsToRemove); - await db.SaveChangesAsync().ConfigureAwait(false); - } - await db.SaveChangesAsync().ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Failed to remove duplicate syscall entries"); - throw; - } - finally - { - Limiter.Release(); - } - return (funcs, links); } } -} + + public static async Task<(int funcs, int links)> FixInvalidFunctionNamesAsync() + { + var syscallStats = new TSyscallStats(); + int funcs, links = 0; + await using var db = new ThumbnailDb(); + var funcsToRemove = new List(0); + try + { + funcsToRemove = db.SyscallInfo.AsEnumerable().Where(sci => sci.Function.Contains('(') || sci.Function.StartsWith('“')).ToList(); + funcs = funcsToRemove.Count; + if (funcs == 0) + return (0, 0); + + foreach (var sci in funcsToRemove.Where(sci => sci.Function.Contains('('))) + { + var productIds = await db.SyscallToProductMap + .AsNoTracking() + .Where(m => m.SyscallInfoId == sci.Id) + .Select(m => m.Product.ProductCode) + .Distinct() + .ToListAsync() + .ConfigureAwait(false); + links += productIds.Count; + foreach (var productId in productIds) + { + if (!syscallStats.TryGetValue(productId, out var smInfo)) + syscallStats[productId] = smInfo = new HashSet(); + smInfo.Add(sci.Function.Split('(', 2)[0]); + } + } + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to build fixed syscall mappings"); + throw; + } + + await SaveAsync(syscallStats).ConfigureAwait(false); + if (!await Limiter.WaitAsync(1000, Config.Cts.Token)) + return (funcs, links); + + try + { + db.SyscallInfo.RemoveRange(funcsToRemove); + await db.SaveChangesAsync().ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to remove broken syscall mappings"); + throw; + } + finally + { + Limiter.Release(); + } + return (funcs, links); + } + + public static async Task<(int funcs, int links)> FixDuplicatesAsync() + { + int funcs = 0, links = 0; + await using var db = new ThumbnailDb(); + var duplicateFunctionNames = await db.SyscallInfo.Where(sci => db.SyscallInfo.Count(isci => isci.Function == sci.Function) > 1).Distinct().ToListAsync().ConfigureAwait(false); + if (duplicateFunctionNames.Count == 0) + return (0, 0); + + if (!await Limiter.WaitAsync(1000, Config.Cts.Token)) + return (funcs, links); + + try + { + foreach (var dupFunc in duplicateFunctionNames) + { + var dups = db.SyscallInfo.Where(sci => sci.Function == dupFunc.Function).ToList(); + if (dups.Count < 2) + continue; + + var mostCommonDup = dups.Select(dup => (dup, count: db.SyscallToProductMap.Count(scm => scm.SyscallInfoId == dup.Id))).OrderByDescending(stat => stat.count).First().dup; + var dupsToRemove = dups.Where(df => df.Id != mostCommonDup.Id).ToList(); + funcs += dupsToRemove.Count; + foreach (var dupToRemove in dupsToRemove) + { + var mappings = db.SyscallToProductMap.Where(scm => scm.SyscallInfoId == dupToRemove.Id).ToList(); + links += mappings.Count; + foreach (var mapping in mappings) + { + if (!db.SyscallToProductMap.Any(scm => scm.ProductId == mapping.ProductId && scm.SyscallInfoId == mostCommonDup.Id)) + await db.SyscallToProductMap.AddAsync(new SyscallToProductMap {ProductId = mapping.ProductId, SyscallInfoId = mostCommonDup.Id}).ConfigureAwait(false); + } + } + await db.SaveChangesAsync().ConfigureAwait(false); + db.SyscallInfo.RemoveRange(dupsToRemove); + await db.SaveChangesAsync().ConfigureAwait(false); + } + await db.SaveChangesAsync().ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Failed to remove duplicate syscall entries"); + throw; + } + finally + { + Limiter.Release(); + } + return (funcs, links); + } +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/ThumbnailProvider.cs b/CompatBot/Database/Providers/ThumbnailProvider.cs index 4db4d8bb..f3edf99c 100644 --- a/CompatBot/Database/Providers/ThumbnailProvider.cs +++ b/CompatBot/Database/Providers/ThumbnailProvider.cs @@ -10,195 +10,194 @@ using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +internal static class ThumbnailProvider { - internal static class ThumbnailProvider + private static readonly HttpClient HttpClient = HttpClientFactory.Create(); + private static readonly PsnClient.Client PsnClient = new(); + private static readonly MemoryCache ColorCache = new(new MemoryCacheOptions{ ExpirationScanFrequency = TimeSpan.FromDays(1) }); + + public static async Task GetThumbnailUrlAsync(this DiscordClient client, string? productCode) { - private static readonly HttpClient HttpClient = HttpClientFactory.Create(); - private static readonly PsnClient.Client PsnClient = new(); - private static readonly MemoryCache ColorCache = new(new MemoryCacheOptions{ ExpirationScanFrequency = TimeSpan.FromDays(1) }); - - public static async Task GetThumbnailUrlAsync(this DiscordClient client, string? productCode) - { - if (string.IsNullOrEmpty(productCode)) - return null; - - productCode = productCode.ToUpperInvariant(); - var tmdbInfo = await PsnClient.GetTitleMetaAsync(productCode, Config.Cts.Token).ConfigureAwait(false); - if (tmdbInfo?.Icon.Url is string tmdbIconUrl) - return tmdbIconUrl; - - await using var db = new ThumbnailDb(); - var thumb = await db.Thumbnail.FirstOrDefaultAsync(t => t.ProductCode == productCode).ConfigureAwait(false); - //todo: add search task if not found - if (thumb?.EmbeddableUrl is string embeddableUrl && !string.IsNullOrEmpty(embeddableUrl)) - return embeddableUrl; - - if (string.IsNullOrEmpty(thumb?.Url) || !ScrapeStateProvider.IsFresh(thumb.Timestamp)) - { - var gameTdbCoverUrl = await GameTdbScraper.GetThumbAsync(productCode).ConfigureAwait(false); - if (!string.IsNullOrEmpty(gameTdbCoverUrl)) - { - if (thumb is null) - thumb = (await db.Thumbnail.AddAsync(new Thumbnail {ProductCode = productCode, Url = gameTdbCoverUrl}).ConfigureAwait(false)).Entity; - else - thumb.Url = gameTdbCoverUrl; - thumb.Timestamp = DateTime.UtcNow.Ticks; - await db.SaveChangesAsync().ConfigureAwait(false); - } - } - - if (string.IsNullOrEmpty(thumb?.Url)) - return null; - - var contentName = thumb.ContentId ?? thumb.ProductCode; - var (embedUrl, _) = await GetEmbeddableUrlAsync(client, contentName, thumb.Url).ConfigureAwait(false); - if (embedUrl is null) - return null; - - thumb.EmbeddableUrl = embedUrl; - await db.SaveChangesAsync().ConfigureAwait(false); - return embedUrl; - } - - public static async Task GetTitleNameAsync(string? productCode, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(productCode)) - return null; - - productCode = productCode.ToUpperInvariant(); - await using var db = new ThumbnailDb(); - var thumb = await db.Thumbnail.FirstOrDefaultAsync( - t => t.ProductCode == productCode, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - if (thumb?.Name is string result) - return result; - - var title = (await PsnClient.GetTitleMetaAsync(productCode, cancellationToken).ConfigureAwait(false))?.Name; - try - { - if (!string.IsNullOrEmpty(title)) - { - if (thumb == null) - await db.Thumbnail.AddAsync(new Thumbnail - { - ProductCode = productCode, - Name = title, - }, cancellationToken).ConfigureAwait(false); - else - thumb.Name = title; - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - } - catch (Exception e) - { - Config.Log.Warn(e); - } - - return title; - } - - public static async Task<(string? url, DiscordColor color)> GetThumbnailUrlWithColorAsync(DiscordClient client, string contentId, DiscordColor defaultColor, string? url = null) - { - if (string.IsNullOrEmpty(contentId)) - throw new ArgumentException("ContentID can't be empty", nameof(contentId)); - - contentId = contentId.ToUpperInvariant(); - await using var db = new ThumbnailDb(); - var info = await db.Thumbnail.FirstOrDefaultAsync(ti => ti.ContentId == contentId, Config.Cts.Token).ConfigureAwait(false); - info ??= new Thumbnail{Url = url}; - if (info.Url is null) - return (null, defaultColor); - - DiscordColor? analyzedColor = null; - if (string.IsNullOrEmpty(info.EmbeddableUrl)) - { - var (embedUrl, image) = await GetEmbeddableUrlAsync(client, contentId, info.Url).ConfigureAwait(false); - if (embedUrl is string eUrl) - { - info.EmbeddableUrl = eUrl; - if (image is byte[] jpg) - { - Config.Log.Trace("Getting dominant color for " + eUrl); - analyzedColor = ColorGetter.Analyze(jpg, defaultColor); - if (analyzedColor.HasValue - && analyzedColor.Value.Value != defaultColor.Value) - info.EmbedColor = analyzedColor.Value.Value; - } - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - } - } - if (!info.EmbedColor.HasValue && !analyzedColor.HasValue - || info.EmbedColor.HasValue && info.EmbedColor.Value == defaultColor.Value) - { - var c = await GetImageColorAsync(info.EmbeddableUrl, defaultColor).ConfigureAwait(false); - if (c.HasValue && c.Value.Value != defaultColor.Value) - { - info.EmbedColor = c.Value.Value; - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - } - } - var color = info.EmbedColor.HasValue ? new DiscordColor(info.EmbedColor.Value) : defaultColor; - return (info.EmbeddableUrl, color); - } - - public static async Task<(string? url, byte[]? image)> GetEmbeddableUrlAsync(DiscordClient client, string contentId, string url) - { - try - { - if (!string.IsNullOrEmpty(Path.GetExtension(url))) - return (url, null); - - await using var imgStream = await HttpClient.GetStreamAsync(url).ConfigureAwait(false); - await using var memStream = Config.MemoryStreamManager.GetStream(); - await imgStream.CopyToAsync(memStream).ConfigureAwait(false); - // minimum jpg size is 119 bytes, png is 67 bytes - if (memStream.Length < 64) - return (null, null); - - memStream.Seek(0, SeekOrigin.Begin); - var spam = await client.GetChannelAsync(Config.ThumbnailSpamId).ConfigureAwait(false); - var message = await spam.SendMessageAsync(new DiscordMessageBuilder().WithFile(contentId + ".jpg", memStream).WithContent(contentId)).ConfigureAwait(false); - url = message.Attachments[0].Url; - return (url, memStream.ToArray()); - } - catch (Exception e) - { - Config.Log.Warn(e); - } - return (null, null); - } - - public static async Task GetImageColorAsync(string? url, DiscordColor? defaultColor = null) - { - try - { - if (string.IsNullOrEmpty(url)) - return null; - - if (ColorCache.TryGetValue(url, out DiscordColor? result)) - return result; - - await using var imgStream = await HttpClient.GetStreamAsync(url).ConfigureAwait(false); - await using var memStream = Config.MemoryStreamManager.GetStream(); - await imgStream.CopyToAsync(memStream).ConfigureAwait(false); - // minimum jpg size is 119 bytes, png is 67 bytes - if (memStream.Length < 64) - return null; - - memStream.Seek(0, SeekOrigin.Begin); - - Config.Log.Trace("Getting dominant color for " + url); - result = ColorGetter.Analyze(memStream.ToArray(), defaultColor); - ColorCache.Set(url, result, TimeSpan.FromHours(1)); - return result; - } - catch (Exception e) - { - Config.Log.Warn(e); - } + if (string.IsNullOrEmpty(productCode)) return null; + + productCode = productCode.ToUpperInvariant(); + var tmdbInfo = await PsnClient.GetTitleMetaAsync(productCode, Config.Cts.Token).ConfigureAwait(false); + if (tmdbInfo?.Icon.Url is string tmdbIconUrl) + return tmdbIconUrl; + + await using var db = new ThumbnailDb(); + var thumb = await db.Thumbnail.FirstOrDefaultAsync(t => t.ProductCode == productCode).ConfigureAwait(false); + //todo: add search task if not found + if (thumb?.EmbeddableUrl is string embeddableUrl && !string.IsNullOrEmpty(embeddableUrl)) + return embeddableUrl; + + if (string.IsNullOrEmpty(thumb?.Url) || !ScrapeStateProvider.IsFresh(thumb.Timestamp)) + { + var gameTdbCoverUrl = await GameTdbScraper.GetThumbAsync(productCode).ConfigureAwait(false); + if (!string.IsNullOrEmpty(gameTdbCoverUrl)) + { + if (thumb is null) + thumb = (await db.Thumbnail.AddAsync(new Thumbnail {ProductCode = productCode, Url = gameTdbCoverUrl}).ConfigureAwait(false)).Entity; + else + thumb.Url = gameTdbCoverUrl; + thumb.Timestamp = DateTime.UtcNow.Ticks; + await db.SaveChangesAsync().ConfigureAwait(false); + } } + + if (string.IsNullOrEmpty(thumb?.Url)) + return null; + + var contentName = thumb.ContentId ?? thumb.ProductCode; + var (embedUrl, _) = await GetEmbeddableUrlAsync(client, contentName, thumb.Url).ConfigureAwait(false); + if (embedUrl is null) + return null; + + thumb.EmbeddableUrl = embedUrl; + await db.SaveChangesAsync().ConfigureAwait(false); + return embedUrl; } -} + + public static async Task GetTitleNameAsync(string? productCode, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(productCode)) + return null; + + productCode = productCode.ToUpperInvariant(); + await using var db = new ThumbnailDb(); + var thumb = await db.Thumbnail.FirstOrDefaultAsync( + t => t.ProductCode == productCode, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + if (thumb?.Name is string result) + return result; + + var title = (await PsnClient.GetTitleMetaAsync(productCode, cancellationToken).ConfigureAwait(false))?.Name; + try + { + if (!string.IsNullOrEmpty(title)) + { + if (thumb == null) + await db.Thumbnail.AddAsync(new Thumbnail + { + ProductCode = productCode, + Name = title, + }, cancellationToken).ConfigureAwait(false); + else + thumb.Name = title; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (Exception e) + { + Config.Log.Warn(e); + } + + return title; + } + + public static async Task<(string? url, DiscordColor color)> GetThumbnailUrlWithColorAsync(DiscordClient client, string contentId, DiscordColor defaultColor, string? url = null) + { + if (string.IsNullOrEmpty(contentId)) + throw new ArgumentException("ContentID can't be empty", nameof(contentId)); + + contentId = contentId.ToUpperInvariant(); + await using var db = new ThumbnailDb(); + var info = await db.Thumbnail.FirstOrDefaultAsync(ti => ti.ContentId == contentId, Config.Cts.Token).ConfigureAwait(false); + info ??= new Thumbnail{Url = url}; + if (info.Url is null) + return (null, defaultColor); + + DiscordColor? analyzedColor = null; + if (string.IsNullOrEmpty(info.EmbeddableUrl)) + { + var (embedUrl, image) = await GetEmbeddableUrlAsync(client, contentId, info.Url).ConfigureAwait(false); + if (embedUrl is string eUrl) + { + info.EmbeddableUrl = eUrl; + if (image is byte[] jpg) + { + Config.Log.Trace("Getting dominant color for " + eUrl); + analyzedColor = ColorGetter.Analyze(jpg, defaultColor); + if (analyzedColor.HasValue + && analyzedColor.Value.Value != defaultColor.Value) + info.EmbedColor = analyzedColor.Value.Value; + } + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + } + } + if (!info.EmbedColor.HasValue && !analyzedColor.HasValue + || info.EmbedColor.HasValue && info.EmbedColor.Value == defaultColor.Value) + { + var c = await GetImageColorAsync(info.EmbeddableUrl, defaultColor).ConfigureAwait(false); + if (c.HasValue && c.Value.Value != defaultColor.Value) + { + info.EmbedColor = c.Value.Value; + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + } + } + var color = info.EmbedColor.HasValue ? new DiscordColor(info.EmbedColor.Value) : defaultColor; + return (info.EmbeddableUrl, color); + } + + public static async Task<(string? url, byte[]? image)> GetEmbeddableUrlAsync(DiscordClient client, string contentId, string url) + { + try + { + if (!string.IsNullOrEmpty(Path.GetExtension(url))) + return (url, null); + + await using var imgStream = await HttpClient.GetStreamAsync(url).ConfigureAwait(false); + await using var memStream = Config.MemoryStreamManager.GetStream(); + await imgStream.CopyToAsync(memStream).ConfigureAwait(false); + // minimum jpg size is 119 bytes, png is 67 bytes + if (memStream.Length < 64) + return (null, null); + + memStream.Seek(0, SeekOrigin.Begin); + var spam = await client.GetChannelAsync(Config.ThumbnailSpamId).ConfigureAwait(false); + var message = await spam.SendMessageAsync(new DiscordMessageBuilder().WithFile(contentId + ".jpg", memStream).WithContent(contentId)).ConfigureAwait(false); + url = message.Attachments[0].Url; + return (url, memStream.ToArray()); + } + catch (Exception e) + { + Config.Log.Warn(e); + } + return (null, null); + } + + public static async Task GetImageColorAsync(string? url, DiscordColor? defaultColor = null) + { + try + { + if (string.IsNullOrEmpty(url)) + return null; + + if (ColorCache.TryGetValue(url, out DiscordColor? result)) + return result; + + await using var imgStream = await HttpClient.GetStreamAsync(url).ConfigureAwait(false); + await using var memStream = Config.MemoryStreamManager.GetStream(); + await imgStream.CopyToAsync(memStream).ConfigureAwait(false); + // minimum jpg size is 119 bytes, png is 67 bytes + if (memStream.Length < 64) + return null; + + memStream.Seek(0, SeekOrigin.Begin); + + Config.Log.Trace("Getting dominant color for " + url); + result = ColorGetter.Analyze(memStream.ToArray(), defaultColor); + ColorCache.Set(url, result, TimeSpan.FromHours(1)); + return result; + } + catch (Exception e) + { + Config.Log.Warn(e); + } + return null; + } +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/TitleUpdateInfoProvider.cs b/CompatBot/Database/Providers/TitleUpdateInfoProvider.cs index be7fe0f1..68ec582c 100644 --- a/CompatBot/Database/Providers/TitleUpdateInfoProvider.cs +++ b/CompatBot/Database/Providers/TitleUpdateInfoProvider.cs @@ -8,71 +8,70 @@ using CompatBot.Utils; using Microsoft.EntityFrameworkCore; using PsnClient.POCOs; -namespace CompatBot.Database.Providers +namespace CompatBot.Database.Providers; + +public static class TitleUpdateInfoProvider { - public static class TitleUpdateInfoProvider + private static readonly PsnClient.Client Client = new(); + + public static async Task GetAsync(string? productId, CancellationToken cancellationToken) { - private static readonly PsnClient.Client Client = new(); + if (string.IsNullOrEmpty(productId)) + return default; - public static async Task GetAsync(string? productId, CancellationToken cancellationToken) + productId = productId.ToUpper(); + var (update, xml) = await Client.GetTitleUpdatesAsync(productId, cancellationToken).ConfigureAwait(false); + if (xml is string {Length: > 10}) { - if (string.IsNullOrEmpty(productId)) - return default; - - productId = productId.ToUpper(); - var (update, xml) = await Client.GetTitleUpdatesAsync(productId, cancellationToken).ConfigureAwait(false); - if (xml is string {Length: > 10}) + var xmlChecksum = xml.GetStableHash(); + await using var db = new ThumbnailDb(); + var updateInfo = db.GameUpdateInfo.FirstOrDefault(ui => ui.ProductCode == productId); + if (updateInfo is null) + db.GameUpdateInfo.Add(new() {ProductCode = productId, MetaHash = xmlChecksum, MetaXml = xml, Timestamp = DateTime.UtcNow.Ticks}); + else if (updateInfo.MetaHash != xmlChecksum && update?.Tag?.Packages is {Length: >0}) { - var xmlChecksum = xml.GetStableHash(); - await using var db = new ThumbnailDb(); - var updateInfo = db.GameUpdateInfo.FirstOrDefault(ui => ui.ProductCode == productId); - if (updateInfo is null) - db.GameUpdateInfo.Add(new() {ProductCode = productId, MetaHash = xmlChecksum, MetaXml = xml, Timestamp = DateTime.UtcNow.Ticks}); - else if (updateInfo.MetaHash != xmlChecksum && update?.Tag?.Packages is {Length: >0}) - { - updateInfo.MetaHash = xmlChecksum; - updateInfo.MetaXml = xml; - updateInfo.Timestamp = DateTime.UtcNow.Ticks; - } - else if (updateInfo.MetaHash == xmlChecksum) - updateInfo.Timestamp = DateTime.UtcNow.Ticks; - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + updateInfo.MetaHash = xmlChecksum; + updateInfo.MetaXml = xml; + updateInfo.Timestamp = DateTime.UtcNow.Ticks; } - if ((update?.Tag?.Packages?.Length ?? 0) == 0) - { - await using var db = new ThumbnailDb(); - var updateInfo = db.GameUpdateInfo.FirstOrDefault(ui => ui.ProductCode == productId); - if (updateInfo is null) - return update; - - await using var memStream = Config.MemoryStreamManager.GetStream(Encoding.UTF8.GetBytes(updateInfo.MetaXml)); - var xmlSerializer = new XmlSerializer(typeof(TitlePatch)); - update = (TitlePatch?)xmlSerializer.Deserialize(memStream); - if (update is not null) - update.OfflineCacheTimestamp = updateInfo.Timestamp.AsUtc(); - } - - return update; + else if (updateInfo.MetaHash == xmlChecksum) + updateInfo.Timestamp = DateTime.UtcNow.Ticks; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - - public static async Task RefreshGameUpdateInfoAsync(CancellationToken cancellationToken) + if ((update?.Tag?.Packages?.Length ?? 0) == 0) { await using var db = new ThumbnailDb(); - do - { - var productCodeList = await db.Thumbnail.AsNoTracking().Select(t => t.ProductCode).ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var titleId in productCodeList) - { - var updateInfo = db.GameUpdateInfo.AsNoTracking().FirstOrDefault(ui => ui.ProductCode == titleId); - if (!cancellationToken.IsCancellationRequested - && ((updateInfo?.Timestamp ?? 0) == 0 || updateInfo!.Timestamp.AsUtc() < DateTime.UtcNow.AddMonths(-1))) - { - await GetAsync(titleId, cancellationToken).ConfigureAwait(false); - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); - } - } - await Task.Delay(TimeSpan.FromDays(1), cancellationToken).ConfigureAwait(false); - } while (!cancellationToken.IsCancellationRequested); + var updateInfo = db.GameUpdateInfo.FirstOrDefault(ui => ui.ProductCode == productId); + if (updateInfo is null) + return update; + + await using var memStream = Config.MemoryStreamManager.GetStream(Encoding.UTF8.GetBytes(updateInfo.MetaXml)); + var xmlSerializer = new XmlSerializer(typeof(TitlePatch)); + update = (TitlePatch?)xmlSerializer.Deserialize(memStream); + if (update is not null) + update.OfflineCacheTimestamp = updateInfo.Timestamp.AsUtc(); } + + return update; + } + + public static async Task RefreshGameUpdateInfoAsync(CancellationToken cancellationToken) + { + await using var db = new ThumbnailDb(); + do + { + var productCodeList = await db.Thumbnail.AsNoTracking().Select(t => t.ProductCode).ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var titleId in productCodeList) + { + var updateInfo = db.GameUpdateInfo.AsNoTracking().FirstOrDefault(ui => ui.ProductCode == titleId); + if (!cancellationToken.IsCancellationRequested + && ((updateInfo?.Timestamp ?? 0) == 0 || updateInfo!.Timestamp.AsUtc() < DateTime.UtcNow.AddMonths(-1))) + { + await GetAsync(titleId, cancellationToken).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); + } + } + await Task.Delay(TimeSpan.FromDays(1), cancellationToken).ConfigureAwait(false); + } while (!cancellationToken.IsCancellationRequested); } } \ No newline at end of file diff --git a/CompatBot/Database/ThumbnailDb.cs b/CompatBot/Database/ThumbnailDb.cs index 2b89a3df..b0b3ea8b 100644 --- a/CompatBot/Database/ThumbnailDb.cs +++ b/CompatBot/Database/ThumbnailDb.cs @@ -4,148 +4,147 @@ using System.ComponentModel.DataAnnotations; using CompatApiClient; using Microsoft.EntityFrameworkCore; -namespace CompatBot.Database +namespace CompatBot.Database; + +internal class ThumbnailDb : DbContext { - internal class ThumbnailDb : DbContext - { - public DbSet State { get; set; } = null!; - public DbSet Thumbnail { get; set; } = null!; - public DbSet GameUpdateInfo { get; set; } = null!; - public DbSet SyscallInfo { get; set; } = null!; - public DbSet SyscallToProductMap { get; set; } = null!; - public DbSet Metacritic { get; set; } = null!; - public DbSet Fortune { get; set; } = null!; - public DbSet NamePool { get; set; } = null!; + public DbSet State { get; set; } = null!; + public DbSet Thumbnail { get; set; } = null!; + public DbSet GameUpdateInfo { get; set; } = null!; + public DbSet SyscallInfo { get; set; } = null!; + public DbSet SyscallToProductMap { get; set; } = null!; + public DbSet Metacritic { get; set; } = null!; + public DbSet Fortune { get; set; } = null!; + public DbSet NamePool { get; set; } = null!; - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - var dbPath = DbImporter.GetDbPath("thumbs.db", Environment.SpecialFolder.LocalApplicationData); + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + var dbPath = DbImporter.GetDbPath("thumbs.db", Environment.SpecialFolder.LocalApplicationData); #if DEBUG - optionsBuilder.UseLoggerFactory(Config.LoggerFactory); + optionsBuilder.UseLoggerFactory(Config.LoggerFactory); #endif - optionsBuilder.UseSqlite($"Data Source=\"{dbPath}\""); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - //configure indices - modelBuilder.Entity().HasIndex(s => s.Locale).IsUnique().HasDatabaseName("state_locale"); - modelBuilder.Entity().HasIndex(s => s.Timestamp).HasDatabaseName("state_timestamp"); - modelBuilder.Entity().HasIndex(m => m.ProductCode).IsUnique().HasDatabaseName("thumbnail_product_code"); - modelBuilder.Entity().HasIndex(m => m.ContentId).IsUnique().HasDatabaseName("thumbnail_content_id"); - modelBuilder.Entity().HasIndex(m => m.Timestamp).HasDatabaseName("thumbnail_timestamp"); - modelBuilder.Entity().HasIndex(ui => ui.ProductCode).IsUnique().HasDatabaseName("game_update_info_product_code"); - modelBuilder.Entity().HasIndex(sci => sci.Function).HasDatabaseName("syscall_info_function"); - modelBuilder.Entity().HasKey(m => new {m.ProductId, m.SyscallInfoId}); - modelBuilder.Entity(); - modelBuilder.Entity(); - - //configure default policy of Id being the primary key - modelBuilder.ConfigureDefaultPkConvention(); - - //configure name conversion for all configured entities from CamelCase to snake_case - modelBuilder.ConfigureMapping(NamingStyles.Underscore); - } + optionsBuilder.UseSqlite($"Data Source=\"{dbPath}\""); } - internal class State + protected override void OnModelCreating(ModelBuilder modelBuilder) { - public int Id { get; set; } - public string? Locale { get; set; } - public long Timestamp { get; set; } - } + //configure indices + modelBuilder.Entity().HasIndex(s => s.Locale).IsUnique().HasDatabaseName("state_locale"); + modelBuilder.Entity().HasIndex(s => s.Timestamp).HasDatabaseName("state_timestamp"); + modelBuilder.Entity().HasIndex(m => m.ProductCode).IsUnique().HasDatabaseName("thumbnail_product_code"); + modelBuilder.Entity().HasIndex(m => m.ContentId).IsUnique().HasDatabaseName("thumbnail_content_id"); + modelBuilder.Entity().HasIndex(m => m.Timestamp).HasDatabaseName("thumbnail_timestamp"); + modelBuilder.Entity().HasIndex(ui => ui.ProductCode).IsUnique().HasDatabaseName("game_update_info_product_code"); + modelBuilder.Entity().HasIndex(sci => sci.Function).HasDatabaseName("syscall_info_function"); + modelBuilder.Entity().HasKey(m => new {m.ProductId, m.SyscallInfoId}); + modelBuilder.Entity(); + modelBuilder.Entity(); - internal class Thumbnail - { - public int Id { get; set; } - [Required] - public string ProductCode { get; set; } = null!; - public string? ContentId { get; set; } - public string? Name { get; set; } - public string? Url { get; set; } - public string? EmbeddableUrl { get; set; } - public long Timestamp { get; set; } - public int? EmbedColor { get; set; } - public CompatStatus? CompatibilityStatus { get; set; } - public long? CompatibilityChangeDate { get; set; } + //configure default policy of Id being the primary key + modelBuilder.ConfigureDefaultPkConvention(); - public int? MetacriticId { get; set; } - public Metacritic? Metacritic { get; set; } - - public List SyscallToProductMap { get; set; } = null!; - } - - internal class GameUpdateInfo - { - public int Id { get; set; } - [Required] - public string ProductCode { get; set; } = null!; - public int MetaHash { get; set; } - [Required] - public string MetaXml { get; set; } = null!; - public long Timestamp { get; set; } - } - - public enum CompatStatus : byte - { - Unknown = 0, - Nothing = 10, - Loadable = 20, - Intro = 30, - Ingame = 40, - Playable = 50, - } - - internal class SyscallInfo - { - public int Id { get; set; } - [Required] - public string Function { get; set; } = null!; - - public List SyscallToProductMap { get; set; } = null!; - } - - internal class SyscallToProductMap - { - public int ProductId { get; set; } - public Thumbnail Product { get; set; } = null!; - - public int SyscallInfoId { get; set; } - public SyscallInfo SyscallInfo { get; set; } = null!; - } - - internal class Metacritic - { - public int Id { get; set; } - [Required] - public string Title { get; set; } = null!; - public byte? CriticScore { get; set; } - public byte? UserScore { get; set; } - public string? Notes { get; set; } - - public Metacritic WithTitle(string title) - { - return new() - { - Title = title, - CriticScore = CriticScore, - UserScore = UserScore, - Notes = Notes, - }; - } - } - - internal class Fortune - { - public int Id { get; set; } - [Required] - public string Content { get; set; } = null!; - } - - internal class NamePool - { - public int Id { get; set; } - [Required] - public string Name { get; set; } = null!; + //configure name conversion for all configured entities from CamelCase to snake_case + modelBuilder.ConfigureMapping(NamingStyles.Underscore); } } + +internal class State +{ + public int Id { get; set; } + public string? Locale { get; set; } + public long Timestamp { get; set; } +} + +internal class Thumbnail +{ + public int Id { get; set; } + [Required] + public string ProductCode { get; set; } = null!; + public string? ContentId { get; set; } + public string? Name { get; set; } + public string? Url { get; set; } + public string? EmbeddableUrl { get; set; } + public long Timestamp { get; set; } + public int? EmbedColor { get; set; } + public CompatStatus? CompatibilityStatus { get; set; } + public long? CompatibilityChangeDate { get; set; } + + public int? MetacriticId { get; set; } + public Metacritic? Metacritic { get; set; } + + public List SyscallToProductMap { get; set; } = null!; +} + +internal class GameUpdateInfo +{ + public int Id { get; set; } + [Required] + public string ProductCode { get; set; } = null!; + public int MetaHash { get; set; } + [Required] + public string MetaXml { get; set; } = null!; + public long Timestamp { get; set; } +} + +public enum CompatStatus : byte +{ + Unknown = 0, + Nothing = 10, + Loadable = 20, + Intro = 30, + Ingame = 40, + Playable = 50, +} + +internal class SyscallInfo +{ + public int Id { get; set; } + [Required] + public string Function { get; set; } = null!; + + public List SyscallToProductMap { get; set; } = null!; +} + +internal class SyscallToProductMap +{ + public int ProductId { get; set; } + public Thumbnail Product { get; set; } = null!; + + public int SyscallInfoId { get; set; } + public SyscallInfo SyscallInfo { get; set; } = null!; +} + +internal class Metacritic +{ + public int Id { get; set; } + [Required] + public string Title { get; set; } = null!; + public byte? CriticScore { get; set; } + public byte? UserScore { get; set; } + public string? Notes { get; set; } + + public Metacritic WithTitle(string title) + { + return new() + { + Title = title, + CriticScore = CriticScore, + UserScore = UserScore, + Notes = Notes, + }; + } +} + +internal class Fortune +{ + public int Id { get; set; } + [Required] + public string Content { get; set; } = null!; +} + +internal class NamePool +{ + public int Id { get; set; } + [Required] + public string Name { get; set; } = null!; +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/BotStatusMonitor.cs b/CompatBot/EventHandlers/BotStatusMonitor.cs index 4b0b0687..eb793735 100644 --- a/CompatBot/EventHandlers/BotStatusMonitor.cs +++ b/CompatBot/EventHandlers/BotStatusMonitor.cs @@ -5,26 +5,25 @@ using DSharpPlus; using DSharpPlus.Entities; using Microsoft.EntityFrameworkCore; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class BotStatusMonitor { - internal static class BotStatusMonitor + public static async Task RefreshAsync(DiscordClient client) { - public static async Task RefreshAsync(DiscordClient client) + try { - try - { - await using var db = new BotDb(); - var status = await db.BotState.FirstOrDefaultAsync(s => s.Key == "bot-status-activity").ConfigureAwait(false); - var txt = await db.BotState.FirstOrDefaultAsync(s => s.Key == "bot-status-text").ConfigureAwait(false); - var msg = txt?.Value; - if (Enum.TryParse(status?.Value ?? "Watching", true, out ActivityType activity) - && !string.IsNullOrEmpty(msg)) - await client.UpdateStatusAsync(new DiscordActivity(msg, activity), UserStatus.Online).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - } + await using var db = new BotDb(); + var status = await db.BotState.FirstOrDefaultAsync(s => s.Key == "bot-status-activity").ConfigureAwait(false); + var txt = await db.BotState.FirstOrDefaultAsync(s => s.Key == "bot-status-text").ConfigureAwait(false); + var msg = txt?.Value; + if (Enum.TryParse(status?.Value ?? "Watching", true, out ActivityType activity) + && !string.IsNullOrEmpty(msg)) + await client.UpdateStatusAsync(new DiscordActivity(msg, activity), UserStatus.Online).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/ContentFilterMonitor.cs b/CompatBot/EventHandlers/ContentFilterMonitor.cs index 014aa597..4a22892a 100644 --- a/CompatBot/EventHandlers/ContentFilterMonitor.cs +++ b/CompatBot/EventHandlers/ContentFilterMonitor.cs @@ -5,31 +5,30 @@ using CompatBot.Utils.Extensions; using DSharpPlus; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class ContentFilterMonitor { - internal static class ContentFilterMonitor + public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) { - public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) - { - args.Handled = !await ContentFilter.IsClean(c, args.Message).ConfigureAwait(false); - } - - public static async Task OnMessageUpdated(DiscordClient c, MessageUpdateEventArgs args) - { - args.Handled = !await ContentFilter.IsClean(c, args.Message).ConfigureAwait(false); - } - - public static async Task OnReaction(DiscordClient c, MessageReactionAddEventArgs e) - { - if (e.User.IsBotSafeCheck()) - return; - - var emoji = c.GetEmoji(":piratethink:", Config.Reactions.PiracyCheck); - if (e.Emoji != emoji) - return; - - var message = await e.Channel.GetMessageAsync(e.Message.Id).ConfigureAwait(false); - await ContentFilter.IsClean(c, message).ConfigureAwait(false); - } + args.Handled = !await ContentFilter.IsClean(c, args.Message).ConfigureAwait(false); } -} + + public static async Task OnMessageUpdated(DiscordClient c, MessageUpdateEventArgs args) + { + args.Handled = !await ContentFilter.IsClean(c, args.Message).ConfigureAwait(false); + } + + public static async Task OnReaction(DiscordClient c, MessageReactionAddEventArgs e) + { + if (e.User.IsBotSafeCheck()) + return; + + var emoji = c.GetEmoji(":piratethink:", Config.Reactions.PiracyCheck); + if (e.Emoji != emoji) + return; + + var message = await e.Channel.GetMessageAsync(e.Message.Id).ConfigureAwait(false); + await ContentFilter.IsClean(c, message).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/DeletedMessagesMonitor.cs b/CompatBot/EventHandlers/DeletedMessagesMonitor.cs index 9e0607ea..f41b4c57 100644 --- a/CompatBot/EventHandlers/DeletedMessagesMonitor.cs +++ b/CompatBot/EventHandlers/DeletedMessagesMonitor.cs @@ -11,73 +11,72 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class DeletedMessagesMonitor { - internal static class DeletedMessagesMonitor + public static readonly MemoryCache RemovedByBotCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromMinutes(10) }); + public static readonly TimeSpan CacheRetainTime = TimeSpan.FromMinutes(1); + private static readonly SemaphoreSlim PostLock = new(1); + + public static async Task OnMessageDeleted(DiscordClient c, MessageDeleteEventArgs e) { - public static readonly MemoryCache RemovedByBotCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromMinutes(10) }); - public static readonly TimeSpan CacheRetainTime = TimeSpan.FromMinutes(1); - private static readonly SemaphoreSlim PostLock = new(1); + if (e.Channel.IsPrivate) + return; - public static async Task OnMessageDeleted(DiscordClient c, MessageDeleteEventArgs e) + var msg = e.Message; + if (msg?.Author == null) + return; + + if (msg.Author.IsCurrent || msg.Author.IsBotSafeCheck()) + return; + + if (RemovedByBotCache.TryGetValue(msg.Id, out _)) + return; + + var usernameWithNickname = msg.Author.GetUsernameWithNickname(c, e.Guild); + var logMsg = msg.Content; + if (msg.Attachments.Any()) + logMsg += Environment.NewLine + Environment.NewLine + string.Join(Environment.NewLine, msg.Attachments.Select(a => $"📎 {a.FileName}")); + Config.Log.Info($"Deleted message from {usernameWithNickname} ({msg.JumpLink}):{Environment.NewLine}{logMsg.TrimStart()}"); + + var logChannel = await c.GetChannelAsync(Config.DeletedMessagesLogChannelId).ConfigureAwait(false); + if (logChannel == null) + return; + + var (attachmentContent, attachmentFilenames) = await msg.DownloadAttachmentsAsync().ConfigureAwait(false); + try { - if (e.Channel.IsPrivate) - return; - - var msg = e.Message; - if (msg?.Author == null) - return; - - if (msg.Author.IsCurrent || msg.Author.IsBotSafeCheck()) - return; - - if (RemovedByBotCache.TryGetValue(msg.Id, out _)) - return; - - var usernameWithNickname = msg.Author.GetUsernameWithNickname(c, e.Guild); - var logMsg = msg.Content; - if (msg.Attachments.Any()) - logMsg += Environment.NewLine + Environment.NewLine + string.Join(Environment.NewLine, msg.Attachments.Select(a => $"📎 {a.FileName}")); - Config.Log.Info($"Deleted message from {usernameWithNickname} ({msg.JumpLink}):{Environment.NewLine}{logMsg.TrimStart()}"); - - var logChannel = await c.GetChannelAsync(Config.DeletedMessagesLogChannelId).ConfigureAwait(false); - if (logChannel == null) - return; - - var (attachmentContent, attachmentFilenames) = await msg.DownloadAttachmentsAsync().ConfigureAwait(false); + var embed = new DiscordEmbedBuilder() + .WithAuthor($"{msg.Author.Username}#{msg.Author.Discriminator} in #{msg.Channel.Name}", iconUrl: msg.Author.AvatarUrl) + .WithDescription(msg.JumpLink.ToString()) + .WithFooter($"Post date: {msg.Timestamp:yyyy-MM-dd HH:mm:ss} ({(DateTime.UtcNow - msg.Timestamp).AsTimeDeltaDescription()} ago)"); + if (attachmentFilenames?.Count > 0) + embed.AddField("Deleted Attachments", string.Join('\n', msg.Attachments.Select(a => $"📎 {a.FileName}"))); + var color = await ThumbnailProvider.GetImageColorAsync(msg.Author.AvatarUrl).ConfigureAwait(false); + if (color.HasValue) + embed.WithColor(color.Value); + await PostLock.WaitAsync().ConfigureAwait(false); try { - var embed = new DiscordEmbedBuilder() - .WithAuthor($"{msg.Author.Username}#{msg.Author.Discriminator} in #{msg.Channel.Name}", iconUrl: msg.Author.AvatarUrl) - .WithDescription(msg.JumpLink.ToString()) - .WithFooter($"Post date: {msg.Timestamp:yyyy-MM-dd HH:mm:ss} ({(DateTime.UtcNow - msg.Timestamp).AsTimeDeltaDescription()} ago)"); - if (attachmentFilenames?.Count > 0) - embed.AddField("Deleted Attachments", string.Join('\n', msg.Attachments.Select(a => $"📎 {a.FileName}"))); - var color = await ThumbnailProvider.GetImageColorAsync(msg.Author.AvatarUrl).ConfigureAwait(false); - if (color.HasValue) - embed.WithColor(color.Value); - await PostLock.WaitAsync().ConfigureAwait(false); - try - { - await logChannel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(embed.Build()).WithAllowedMentions(Config.AllowedMentions.Nothing)).ConfigureAwait(false); - if (attachmentContent?.Count > 0) - await logChannel.SendMessageAsync(new DiscordMessageBuilder().WithFiles(attachmentContent).WithContent(msg.Content).WithAllowedMentions(Config.AllowedMentions.Nothing)).ConfigureAwait(false); - else if (!string.IsNullOrEmpty(msg.Content)) - await logChannel.SendMessageAsync(new DiscordMessageBuilder().WithContent(msg.Content).WithAllowedMentions(Config.AllowedMentions.Nothing)).ConfigureAwait(false); - } - finally - { - PostLock.Release(); - } + await logChannel.SendMessageAsync(new DiscordMessageBuilder().WithEmbed(embed.Build()).WithAllowedMentions(Config.AllowedMentions.Nothing)).ConfigureAwait(false); + if (attachmentContent?.Count > 0) + await logChannel.SendMessageAsync(new DiscordMessageBuilder().WithFiles(attachmentContent).WithContent(msg.Content).WithAllowedMentions(Config.AllowedMentions.Nothing)).ConfigureAwait(false); + else if (!string.IsNullOrEmpty(msg.Content)) + await logChannel.SendMessageAsync(new DiscordMessageBuilder().WithContent(msg.Content).WithAllowedMentions(Config.AllowedMentions.Nothing)).ConfigureAwait(false); } finally { - if (attachmentContent?.Count > 0) - foreach (var f in attachmentContent.Values) -#pragma warning disable VSTHRD103 - f.Dispose(); -#pragma warning restore VSTHRD103 + PostLock.Release(); } } + finally + { + if (attachmentContent?.Count > 0) + foreach (var f in attachmentContent.Values) +#pragma warning disable VSTHRD103 + f.Dispose(); +#pragma warning restore VSTHRD103 + } } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/DiscordInviteFilter.cs b/CompatBot/EventHandlers/DiscordInviteFilter.cs index 2f783c5b..2fdb29a3 100644 --- a/CompatBot/EventHandlers/DiscordInviteFilter.cs +++ b/CompatBot/EventHandlers/DiscordInviteFilter.cs @@ -16,283 +16,282 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class DiscordInviteFilter { - internal static class DiscordInviteFilter + private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.Multiline; + private static readonly Regex InviteLink = new(@"(https?://)?discord(((app\.com/invite|\.gg)/(?[a-z0-9\-]+))|(\.me/(?.*?))(\s|>|$))", DefaultOptions); + private static readonly MemoryCache InviteCodeCache = new(new MemoryCacheOptions{ExpirationScanFrequency = TimeSpan.FromHours(1)}); + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24); + + public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) { - private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.Multiline; - private static readonly Regex InviteLink = new(@"(https?://)?discord(((app\.com/invite|\.gg)/(?[a-z0-9\-]+))|(\.me/(?.*?))(\s|>|$))", DefaultOptions); - private static readonly MemoryCache InviteCodeCache = new(new MemoryCacheOptions{ExpirationScanFrequency = TimeSpan.FromHours(1)}); - private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24); + args.Handled = !await CheckMessageForInvitesAsync(c, args.Message).ConfigureAwait(false); + } - public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) - { - args.Handled = !await CheckMessageForInvitesAsync(c, args.Message).ConfigureAwait(false); - } + public static async Task OnMessageUpdated(DiscordClient c, MessageUpdateEventArgs args) + { + args.Handled = !await CheckMessageForInvitesAsync(c, args.Message).ConfigureAwait(false); + } - public static async Task OnMessageUpdated(DiscordClient c, MessageUpdateEventArgs args) + public static async Task CheckBacklogAsync(DiscordClient client, DiscordGuild guild) + { + try { - args.Handled = !await CheckMessageForInvitesAsync(c, args.Message).ConfigureAwait(false); - } - - public static async Task CheckBacklogAsync(DiscordClient client, DiscordGuild guild) - { - try + var botMember = client.GetMember(guild, client.CurrentUser); + if (botMember == null) { - var botMember = client.GetMember(guild, client.CurrentUser); + await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + botMember = client.GetMember(guild, client.CurrentUser); if (botMember == null) { - await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); - botMember = client.GetMember(guild, client.CurrentUser); - if (botMember == null) - { - Config.Log.Error("Failed to resolve bot as the guild member for guild " + guild); - return; - } - } - - var after = DateTime.UtcNow - Config.ModerationBacklogThresholdInHours; - foreach (var channel in guild.Channels.Values.Where(ch => !ch.IsCategory && ch.Type != ChannelType.Voice)) - { - var permissions = channel.PermissionsFor(botMember); - if (!permissions.HasPermission(Permissions.ReadMessageHistory)) - { - Config.Log.Warn($"No permissions to read message history in #{channel.Name}"); - continue; - } - - if (!permissions.HasPermission(Permissions.AccessChannels)) - { - Config.Log.Warn($"No permissions to access #{channel.Name}"); - continue; - } - - try - { - var messages = await channel.GetMessagesCachedAsync(100).ConfigureAwait(false); - var messagesToCheck = from msg in messages - where msg.CreationTimestamp > after - select msg; - foreach (var message in messagesToCheck) - await CheckMessageForInvitesAsync(client, message).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn($"Some missing permissions in #{channel.Name}: {e.Message}"); - } + Config.Log.Error("Failed to resolve bot as the guild member for guild " + guild); + return; } } - catch (Exception e) + + var after = DateTime.UtcNow - Config.ModerationBacklogThresholdInHours; + foreach (var channel in guild.Channels.Values.Where(ch => !ch.IsCategory && ch.Type != ChannelType.Voice)) { - Config.Log.Error(e); + var permissions = channel.PermissionsFor(botMember); + if (!permissions.HasPermission(Permissions.ReadMessageHistory)) + { + Config.Log.Warn($"No permissions to read message history in #{channel.Name}"); + continue; + } + + if (!permissions.HasPermission(Permissions.AccessChannels)) + { + Config.Log.Warn($"No permissions to access #{channel.Name}"); + continue; + } + + try + { + var messages = await channel.GetMessagesCachedAsync(100).ConfigureAwait(false); + var messagesToCheck = from msg in messages + where msg.CreationTimestamp > after + select msg; + foreach (var message in messagesToCheck) + await CheckMessageForInvitesAsync(client, message).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn($"Some missing permissions in #{channel.Name}: {e.Message}"); + } } } - - public static async Task CheckMessageForInvitesAsync(DiscordClient client, DiscordMessage message) + catch (Exception e) { - if (message.Channel.IsPrivate) - return true; + Config.Log.Error(e); + } + } - if (message.Author.IsBotSafeCheck()) - return true; + public static async Task CheckMessageForInvitesAsync(DiscordClient client, DiscordMessage message) + { + if (message.Channel.IsPrivate) + return true; + + if (message.Author.IsBotSafeCheck()) + return true; #if !DEBUG if (message.Author.IsWhitelisted(client, message.Channel.Guild)) return true; #endif - if (message.Reactions.Any(r => r.Emoji == Config.Reactions.Moderated && r.IsMe)) - return true; + if (message.Reactions.Any(r => r.Emoji == Config.Reactions.Moderated && r.IsMe)) + return true; - var (hasInvalidResults, attemptedWorkaround, invites) = await client.GetInvitesAsync(message.Content, message.Author).ConfigureAwait(false); - if (!hasInvalidResults && invites.Count == 0) - return true; + var (hasInvalidResults, attemptedWorkaround, invites) = await client.GetInvitesAsync(message.Content, message.Author).ConfigureAwait(false); + if (!hasInvalidResults && invites.Count == 0) + return true; - if (hasInvalidResults) + if (hasInvalidResults) + { + try { + DeletedMessagesMonitor.RemovedByBotCache.Set(message.Id, true, DeletedMessagesMonitor.CacheRetainTime); + await message.DeleteAsync("Not a white-listed discord invite link").ConfigureAwait(false); + await client.ReportAsync("🛃 An unapproved discord invite", message, "In invalid or expired invite", null, null, null, ReportSeverity.Low).ConfigureAwait(false); + await message.Channel.SendMessageAsync($"{message.Author.Mention} please refrain from posting invites that were not approved by a moderator, especially expired or invalid.").ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e); + await client.ReportAsync("🛃 An unapproved discord invite", message, "In invalid or expired invite", null, null, null, ReportSeverity.Medium).ConfigureAwait(false); + await message.ReactWithAsync(Config.Reactions.Moderated, + $"{message.Author.Mention} please remove this expired or invalid invite, and refrain from posting it again until you have received an approval from a moderator.", + true + ).ConfigureAwait(false); + } + return false; + } + + foreach (var invite in invites) + { + if (!await InviteWhitelistProvider.IsWhitelistedAsync(invite).ConfigureAwait(false)) + { + if (!InviteCodeCache.TryGetValue(message.Author.Id, out HashSet recentInvites)) + recentInvites = new HashSet(); + var circumventionAttempt = !recentInvites.Add(invite.Code) && attemptedWorkaround; //do not flip, must add to cache always + InviteCodeCache.Set(message.Author.Id, recentInvites, CacheDuration); + var removed = false; try { DeletedMessagesMonitor.RemovedByBotCache.Set(message.Id, true, DeletedMessagesMonitor.CacheRetainTime); - await message.DeleteAsync("Not a white-listed discord invite link").ConfigureAwait(false); - await client.ReportAsync("🛃 An unapproved discord invite", message, "In invalid or expired invite", null, null, null, ReportSeverity.Low).ConfigureAwait(false); - await message.Channel.SendMessageAsync($"{message.Author.Mention} please refrain from posting invites that were not approved by a moderator, especially expired or invalid.").ConfigureAwait(false); + await message.DeleteAsync("Not a white-listed discord invite").ConfigureAwait(false); + removed = true; } catch (Exception e) { Config.Log.Warn(e); - await client.ReportAsync("🛃 An unapproved discord invite", message, "In invalid or expired invite", null, null, null, ReportSeverity.Medium).ConfigureAwait(false); - await message.ReactWithAsync(Config.Reactions.Moderated, - $"{message.Author.Mention} please remove this expired or invalid invite, and refrain from posting it again until you have received an approval from a moderator.", - true - ).ConfigureAwait(false); } + + var codeResolveMsg = $"Invite {invite.Code} was resolved to the {invite.Guild?.Name} server"; + var reportMsg = codeResolveMsg; + string userMsg; + if (circumventionAttempt) + { + reportMsg += "\nAlso tried to workaround filter despite being asked not to do so."; + userMsg = $"{message.Author.Mention} you have been asked nicely to not post invites to this unapproved discord server before."; + } + else + { + userMsg = $"{message.Author.Mention} invites to other servers must be whitelisted first.\n"; + if (removed) + userMsg += "Please refrain from posting it again until you have received an approval from a moderator."; + else + userMsg += "Please remove it and refrain from posting it again until you have received an approval from a moderator."; + } + await client.ReportAsync("🛃 An unapproved discord invite", message, reportMsg, null, null, null, ReportSeverity.Low).ConfigureAwait(false); + await message.Channel.SendMessageAsync(userMsg).ConfigureAwait(false); + if (circumventionAttempt) + await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Attempted to circumvent discord invite filter", codeResolveMsg); return false; } - - foreach (var invite in invites) - { - if (!await InviteWhitelistProvider.IsWhitelistedAsync(invite).ConfigureAwait(false)) - { - if (!InviteCodeCache.TryGetValue(message.Author.Id, out HashSet recentInvites)) - recentInvites = new HashSet(); - var circumventionAttempt = !recentInvites.Add(invite.Code) && attemptedWorkaround; //do not flip, must add to cache always - InviteCodeCache.Set(message.Author.Id, recentInvites, CacheDuration); - var removed = false; - try - { - DeletedMessagesMonitor.RemovedByBotCache.Set(message.Id, true, DeletedMessagesMonitor.CacheRetainTime); - await message.DeleteAsync("Not a white-listed discord invite").ConfigureAwait(false); - removed = true; - } - catch (Exception e) - { - Config.Log.Warn(e); - } - - var codeResolveMsg = $"Invite {invite.Code} was resolved to the {invite.Guild?.Name} server"; - var reportMsg = codeResolveMsg; - string userMsg; - if (circumventionAttempt) - { - reportMsg += "\nAlso tried to workaround filter despite being asked not to do so."; - userMsg = $"{message.Author.Mention} you have been asked nicely to not post invites to this unapproved discord server before."; - } - else - { - userMsg = $"{message.Author.Mention} invites to other servers must be whitelisted first.\n"; - if (removed) - userMsg += "Please refrain from posting it again until you have received an approval from a moderator."; - else - userMsg += "Please remove it and refrain from posting it again until you have received an approval from a moderator."; - } - await client.ReportAsync("🛃 An unapproved discord invite", message, reportMsg, null, null, null, ReportSeverity.Low).ConfigureAwait(false); - await message.Channel.SendMessageAsync(userMsg).ConfigureAwait(false); - if (circumventionAttempt) - await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Attempted to circumvent discord invite filter", codeResolveMsg); - return false; - } - } - return true; } + return true; + } - public static async Task<(bool hasInvalidInvite, bool attemptToWorkaround, List invites)> GetInvitesAsync(this DiscordClient client, string message, DiscordUser? author = null, bool tryMessageAsACode = false) + public static async Task<(bool hasInvalidInvite, bool attemptToWorkaround, List invites)> GetInvitesAsync(this DiscordClient client, string message, DiscordUser? author = null, bool tryMessageAsACode = false) + { + if (string.IsNullOrEmpty(message)) + return (false, false, new List(0)); + + var inviteCodes = new HashSet(InviteLink.Matches(message).Select(m => m.Groups["invite_id"].Value).Where(s => !string.IsNullOrEmpty(s))); + var discordMeLinks = InviteLink.Matches(message).Select(m => m.Groups["me_id"].Value).Distinct().Where(s => !string.IsNullOrEmpty(s)).ToList(); + var attemptedWorkaround = false; + if (author != null && InviteCodeCache.TryGetValue(author.Id, out HashSet recentInvites)) { - if (string.IsNullOrEmpty(message)) - return (false, false, new List(0)); - - var inviteCodes = new HashSet(InviteLink.Matches(message).Select(m => m.Groups["invite_id"].Value).Where(s => !string.IsNullOrEmpty(s))); - var discordMeLinks = InviteLink.Matches(message).Select(m => m.Groups["me_id"].Value).Distinct().Where(s => !string.IsNullOrEmpty(s)).ToList(); - var attemptedWorkaround = false; - if (author != null && InviteCodeCache.TryGetValue(author.Id, out HashSet recentInvites)) - { - foreach (var c in recentInvites) - if (message.Contains(c)) - { - attemptedWorkaround |= inviteCodes.Add(c); - InviteCodeCache.Set(author.Id, recentInvites, CacheDuration); - } - } - if (inviteCodes.Count == 0 && discordMeLinks.Count == 0 && !tryMessageAsACode) - return (false, attemptedWorkaround, new List(0)); - - var hasInvalidInvites = false; - foreach (var meLink in discordMeLinks) - { - /* - * discord.me is a fucking joke and so far they were unwilling to provide any kind of sane api - * here's their current flow: - * 1. get vanity page (e.g. https://discord.me/rpcs3) - * 2. POST web form with csrf token and server EID to https://discord.me/server/join - * 3. this will return a 302 redirect (Location header value) to https://discord.me/server/join/protected/_some_id_ - * 4. this page will have a "refresh" meta tag in its body to ttps://discord.me/server/join/redirect/_same_id_ - * 5. this one will return a 302 redirect to an actual https://discord.gg/_invite_id_ - */ - try + foreach (var c in recentInvites) + if (message.Contains(c)) { - using var handler = new HttpClientHandler {AllowAutoRedirect = false}; // needed to store cloudflare session cookies - using var httpClient = HttpClientFactory.Create(handler, new CompressionMessageHandler()); - using var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.me/" + meLink); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); - request.Headers.CacheControl = CacheControlHeaderValue.Parse("no-cache"); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RPCS3CompatibilityBot", "2.0")); - using var response = await httpClient.SendAsync(request).ConfigureAwait(false); - var html = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - if (response.IsSuccessStatusCode) - { - if (string.IsNullOrEmpty(html)) - continue; + attemptedWorkaround |= inviteCodes.Add(c); + InviteCodeCache.Set(author.Id, recentInvites, CacheDuration); + } + } + if (inviteCodes.Count == 0 && discordMeLinks.Count == 0 && !tryMessageAsACode) + return (false, attemptedWorkaround, new List(0)); - hasInvalidInvites = true; - var csrfTokenMatch = Regex.Match(html, @"name=""csrf-token"" content=""(?\w+)"""); - var serverEidMatch = Regex.Match(html, @"name=""serverEid"" value=""(?\w+)"""); - if (csrfTokenMatch.Success && serverEidMatch.Success) + var hasInvalidInvites = false; + foreach (var meLink in discordMeLinks) + { + /* + * discord.me is a fucking joke and so far they were unwilling to provide any kind of sane api + * here's their current flow: + * 1. get vanity page (e.g. https://discord.me/rpcs3) + * 2. POST web form with csrf token and server EID to https://discord.me/server/join + * 3. this will return a 302 redirect (Location header value) to https://discord.me/server/join/protected/_some_id_ + * 4. this page will have a "refresh" meta tag in its body to ttps://discord.me/server/join/redirect/_same_id_ + * 5. this one will return a 302 redirect to an actual https://discord.gg/_invite_id_ + */ + try + { + using var handler = new HttpClientHandler {AllowAutoRedirect = false}; // needed to store cloudflare session cookies + using var httpClient = HttpClientFactory.Create(handler, new CompressionMessageHandler()); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.me/" + meLink); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); + request.Headers.CacheControl = CacheControlHeaderValue.Parse("no-cache"); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RPCS3CompatibilityBot", "2.0")); + using var response = await httpClient.SendAsync(request).ConfigureAwait(false); + var html = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + if (string.IsNullOrEmpty(html)) + continue; + + hasInvalidInvites = true; + var csrfTokenMatch = Regex.Match(html, @"name=""csrf-token"" content=""(?\w+)"""); + var serverEidMatch = Regex.Match(html, @"name=""serverEid"" value=""(?\w+)"""); + if (csrfTokenMatch.Success && serverEidMatch.Success) + { + using var postRequest = new HttpRequestMessage(HttpMethod.Post, "https://discord.me/server/join") { - using var postRequest = new HttpRequestMessage(HttpMethod.Post, "https://discord.me/server/join") + Content = new FormUrlEncodedContent(new Dictionary { - Content = new FormUrlEncodedContent(new Dictionary - { - ["_token"] = csrfTokenMatch.Groups["csrf_token"].Value, - ["serverEid"] = serverEidMatch.Groups["server_eid"].Value, - }!), - }; - postRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); - postRequest.Headers.UserAgent.Add(new ProductInfoHeaderValue("RPCS3CompatibilityBot", "2.0")); - using var postResponse = await httpClient.SendAsync(postRequest).ConfigureAwait(false); - if (postResponse.StatusCode == HttpStatusCode.Redirect) + ["_token"] = csrfTokenMatch.Groups["csrf_token"].Value, + ["serverEid"] = serverEidMatch.Groups["server_eid"].Value, + }!), + }; + postRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); + postRequest.Headers.UserAgent.Add(new ProductInfoHeaderValue("RPCS3CompatibilityBot", "2.0")); + using var postResponse = await httpClient.SendAsync(postRequest).ConfigureAwait(false); + if (postResponse.StatusCode == HttpStatusCode.Redirect) + { + var redirectId = postResponse.Headers.Location?.Segments.Last(); + if (redirectId != null) { - var redirectId = postResponse.Headers.Location?.Segments.Last(); - if (redirectId != null) + using var getDiscordRequest = new HttpRequestMessage(HttpMethod.Get, "https://discord.me/server/join/redirect/" + redirectId); + getDiscordRequest.Headers.CacheControl = CacheControlHeaderValue.Parse("no-cache"); + getDiscordRequest.Headers.UserAgent.Add(new ProductInfoHeaderValue("RPCS3CompatibilityBot", "2.0")); + using var discordRedirect = await httpClient.SendAsync(getDiscordRequest).ConfigureAwait(false); + if (discordRedirect.StatusCode == HttpStatusCode.Redirect) { - using var getDiscordRequest = new HttpRequestMessage(HttpMethod.Get, "https://discord.me/server/join/redirect/" + redirectId); - getDiscordRequest.Headers.CacheControl = CacheControlHeaderValue.Parse("no-cache"); - getDiscordRequest.Headers.UserAgent.Add(new ProductInfoHeaderValue("RPCS3CompatibilityBot", "2.0")); - using var discordRedirect = await httpClient.SendAsync(getDiscordRequest).ConfigureAwait(false); - if (discordRedirect.StatusCode == HttpStatusCode.Redirect) + var inviteCodeSegment = discordRedirect.Headers.Location?.Segments.Last(); + if (inviteCodeSegment != null) { - var inviteCodeSegment = discordRedirect.Headers.Location?.Segments.Last(); - if (inviteCodeSegment != null) - { - inviteCodes.Add(inviteCodeSegment); - hasInvalidInvites = false; - } + inviteCodes.Add(inviteCodeSegment); + hasInvalidInvites = false; } - else - Config.Log.Warn($"Unexpected response code from GET discord redirect: {discordRedirect.StatusCode}"); } else - Config.Log.Warn($"Failed to get redirection URL from {postResponse.RequestMessage?.RequestUri}"); + Config.Log.Warn($"Unexpected response code from GET discord redirect: {discordRedirect.StatusCode}"); } else - Config.Log.Warn($"Unexpected response code from POST: {postResponse.StatusCode}"); + Config.Log.Warn($"Failed to get redirection URL from {postResponse.RequestMessage?.RequestUri}"); } else - Config.Log.Warn($"Failed to get POST arguments from discord.me: {html}"); + Config.Log.Warn($"Unexpected response code from POST: {postResponse.StatusCode}"); } else - Config.Log.Warn($"Got {response.StatusCode} from discord.me: {html}"); - } - catch (Exception e) - { - Config.Log.Warn(e); + Config.Log.Warn($"Failed to get POST arguments from discord.me: {html}"); } + else + Config.Log.Warn($"Got {response.StatusCode} from discord.me: {html}"); + } + catch (Exception e) + { + Config.Log.Warn(e); } - - if (tryMessageAsACode) - inviteCodes.Add(message); - - var result = new List(inviteCodes.Count); - foreach (var inviteCode in inviteCodes) - try - { - if (await client.GetInviteByCodeAsync(inviteCode).ConfigureAwait(false) is DiscordInvite invite) - result.Add(invite); - } - catch (Exception e) - { - hasInvalidInvites = true; - Config.Log.Warn(e, $"Failed to get invite for code {inviteCode}"); - } - return (hasInvalidInvites, attemptedWorkaround, result); } + + if (tryMessageAsACode) + inviteCodes.Add(message); + + var result = new List(inviteCodes.Count); + foreach (var inviteCode in inviteCodes) + try + { + if (await client.GetInviteByCodeAsync(inviteCode).ConfigureAwait(false) is DiscordInvite invite) + result.Add(invite); + } + catch (Exception e) + { + hasInvalidInvites = true; + Config.Log.Warn(e, $"Failed to get invite for code {inviteCode}"); + } + return (hasInvalidInvites, attemptedWorkaround, result); } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/EmpathySimulationHandler.cs b/CompatBot/EventHandlers/EmpathySimulationHandler.cs index 93d9cb9c..2f0cda3b 100644 --- a/CompatBot/EventHandlers/EmpathySimulationHandler.cs +++ b/CompatBot/EventHandlers/EmpathySimulationHandler.cs @@ -10,103 +10,102 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +using TCache = ConcurrentDictionary>; + +internal static class EmpathySimulationHandler { - using TCache = ConcurrentDictionary>; + private static readonly TCache MessageQueue = new(); + internal static readonly TimeSpan ThrottleDuration = TimeSpan.FromHours(1); + internal static readonly MemoryCache Throttling = new(new MemoryCacheOptions {ExpirationScanFrequency = TimeSpan.FromMinutes(30)}); - internal static class EmpathySimulationHandler + public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) { - private static readonly TCache MessageQueue = new(); - internal static readonly TimeSpan ThrottleDuration = TimeSpan.FromHours(1); - internal static readonly MemoryCache Throttling = new(new MemoryCacheOptions {ExpirationScanFrequency = TimeSpan.FromMinutes(30)}); + if (DefaultHandlerFilter.IsFluff(args.Message)) + return; - public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) + if (args.Channel.IsPrivate) + return; + + if (args.Author.IsCurrent) + return; + + if (args.Author.Id == 197163728867688448ul) + return; + + if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) + MessageQueue[args.Channel.Id] = queue = new(); + queue.Enqueue(args.Message); + while (queue.Count > 10) + queue.TryDequeue(out var _); + var content = args.Message.Content; + if (string.IsNullOrEmpty(content)) + return; + + //todo: throttle multiple strings at the same time + if (Throttling.TryGetValue(args.Channel.Id, out List mark) && content.Equals(mark.FirstOrDefault()?.Content, StringComparison.OrdinalIgnoreCase)) { - if (DefaultHandlerFilter.IsFluff(args.Message)) - return; - - if (args.Channel.IsPrivate) - return; - - if (args.Author.IsCurrent) - return; - - if (args.Author.Id == 197163728867688448ul) - return; - - if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) - MessageQueue[args.Channel.Id] = queue = new(); - queue.Enqueue(args.Message); - while (queue.Count > 10) - queue.TryDequeue(out var _); - var content = args.Message.Content; - if (string.IsNullOrEmpty(content)) - return; - - //todo: throttle multiple strings at the same time - if (Throttling.TryGetValue(args.Channel.Id, out List mark) && content.Equals(mark.FirstOrDefault()?.Content, StringComparison.OrdinalIgnoreCase)) - { - mark.Add(args.Message); - Config.Log.Debug($"Bailed out of repeating '{content}' due to throttling"); - return; - } - - var similarList = queue.Where(msg => content.Equals(msg.Content, StringComparison.OrdinalIgnoreCase)).ToList(); - if (similarList.Count > 2) - { - var uniqueUsers = similarList.Select(msg => msg.Author.Id).Distinct().Count(); - if (uniqueUsers > 2) - { - Throttling.Set(args.Channel.Id, similarList, ThrottleDuration); - var msgContent = GetAvgContent(similarList.Select(m => m.Content).ToList()); - var botMsg = await args.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent(msgContent).WithAllowedMentions(Config.AllowedMentions.UsersOnly)).ConfigureAwait(false); - similarList.Add(botMsg); - } - else - Config.Log.Debug($"Bailed out of repeating '{content}' due to {uniqueUsers} unique users"); - } + mark.Add(args.Message); + Config.Log.Debug($"Bailed out of repeating '{content}' due to throttling"); + return; } - public static Task OnMessageUpdated(DiscordClient _, MessageUpdateEventArgs e) => Backtrack(e.Channel, e.MessageBefore, false); - public static Task OnMessageDeleted(DiscordClient _, MessageDeleteEventArgs e) => Backtrack(e.Channel, e.Message, true); - - private static async Task Backtrack(DiscordChannel channel, DiscordMessage message, bool removeFromQueue) + var similarList = queue.Where(msg => content.Equals(msg.Content, StringComparison.OrdinalIgnoreCase)).ToList(); + if (similarList.Count > 2) { - if (channel.IsPrivate) - return; - - if (message.Author == null) - return; - - if (message.Author.IsCurrent) - return; - - if (!Throttling.TryGetValue(channel.Id, out List msgList)) - return; - - if (msgList.Any(m => m.Id == message.Id)) + var uniqueUsers = similarList.Select(msg => msg.Author.Id).Distinct().Count(); + if (uniqueUsers > 2) { - var botMsg = msgList.Last(); - if (botMsg.Id == message.Id) - return; - - try - { - await channel.DeleteMessageAsync(botMsg).ConfigureAwait(false); - if (removeFromQueue) - MessageQueue.TryRemove(message.Id, out _); - } - catch { } + Throttling.Set(args.Channel.Id, similarList, ThrottleDuration); + var msgContent = GetAvgContent(similarList.Select(m => m.Content).ToList()); + var botMsg = await args.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent(msgContent).WithAllowedMentions(Config.AllowedMentions.UsersOnly)).ConfigureAwait(false); + similarList.Add(botMsg); } - } - - private static string GetAvgContent(List samples) - { - var rng = new Random(); - var result = new StringBuilder(samples[0].Length); - for (var i = 0; i < samples[0].Length; i++) - result.Append(samples[rng.Next(samples.Count)][i]); - return result.ToString(); + else + Config.Log.Debug($"Bailed out of repeating '{content}' due to {uniqueUsers} unique users"); } } -} + + public static Task OnMessageUpdated(DiscordClient _, MessageUpdateEventArgs e) => Backtrack(e.Channel, e.MessageBefore, false); + public static Task OnMessageDeleted(DiscordClient _, MessageDeleteEventArgs e) => Backtrack(e.Channel, e.Message, true); + + private static async Task Backtrack(DiscordChannel channel, DiscordMessage message, bool removeFromQueue) + { + if (channel.IsPrivate) + return; + + if (message.Author == null) + return; + + if (message.Author.IsCurrent) + return; + + if (!Throttling.TryGetValue(channel.Id, out List msgList)) + return; + + if (msgList.Any(m => m.Id == message.Id)) + { + var botMsg = msgList.Last(); + if (botMsg.Id == message.Id) + return; + + try + { + await channel.DeleteMessageAsync(botMsg).ConfigureAwait(false); + if (removeFromQueue) + MessageQueue.TryRemove(message.Id, out _); + } + catch { } + } + } + + private static string GetAvgContent(List samples) + { + var rng = new Random(); + var result = new StringBuilder(samples[0].Length); + for (var i = 0; i < samples[0].Length; i++) + result.Append(samples[rng.Next(samples.Count)][i]); + return result.ToString(); + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/GithubLinksHandler.cs b/CompatBot/EventHandlers/GithubLinksHandler.cs index 98233c90..ff228619 100644 --- a/CompatBot/EventHandlers/GithubLinksHandler.cs +++ b/CompatBot/EventHandlers/GithubLinksHandler.cs @@ -9,91 +9,90 @@ using CompatBot.Utils; using DSharpPlus; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class GithubLinksHandler { - internal static class GithubLinksHandler + private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture; + public static readonly Regex IssueMention = new(@"(?\b(issue|pr|pull[ \-]request|bug)\s*#?\s*(?\d+)|\B#(?1?\d{4})|(https?://)github.com/RPCS3/rpcs3/(issues|pull)/(?\d+)(#issuecomment-(?\d+))?)\b", DefaultOptions); + public static readonly Regex CommitMention = new(@"(?(https?://)github.com/RPCS3/rpcs3/commit/(?[0-9a-f]+))\b", DefaultOptions); + public static readonly Regex ImageMarkup = new(@"(?!\[(?[^\]]+)\]\((?\w+://[^\)]+)\))", DefaultOptions); + private static readonly Regex IssueLink = new(@"github.com/RPCS3/rpcs3/issues/(?\d+)", DefaultOptions); + + public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) { - private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture; - public static readonly Regex IssueMention = new(@"(?\b(issue|pr|pull[ \-]request|bug)\s*#?\s*(?\d+)|\B#(?1?\d{4})|(https?://)github.com/RPCS3/rpcs3/(issues|pull)/(?\d+)(#issuecomment-(?\d+))?)\b", DefaultOptions); - public static readonly Regex CommitMention = new(@"(?(https?://)github.com/RPCS3/rpcs3/commit/(?[0-9a-f]+))\b", DefaultOptions); - public static readonly Regex ImageMarkup = new(@"(?!\[(?[^\]]+)\]\((?\w+://[^\)]+)\))", DefaultOptions); - private static readonly Regex IssueLink = new(@"github.com/RPCS3/rpcs3/issues/(?\d+)", DefaultOptions); + if (DefaultHandlerFilter.IsFluff(args.Message)) + return; - public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) + if ("media".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) + return; + + var lastBotMessages = await args.Channel.GetMessagesBeforeAsync(args.Message.Id, 20, DateTime.UtcNow.AddSeconds(-30)).ConfigureAwait(false); + foreach (var msg in lastBotMessages) + if (BotReactionsHandler.NeedToSilence(msg).needToChill) + return; + + lastBotMessages = await args.Channel.GetMessagesBeforeCachedAsync(args.Message.Id, Config.ProductCodeLookupHistoryThrottle).ConfigureAwait(false); + StringBuilder? previousRepliesBuilder = null; + foreach (var msg in lastBotMessages) { - if (DefaultHandlerFilter.IsFluff(args.Message)) - return; - - if ("media".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) - return; - - var lastBotMessages = await args.Channel.GetMessagesBeforeAsync(args.Message.Id, 20, DateTime.UtcNow.AddSeconds(-30)).ConfigureAwait(false); - foreach (var msg in lastBotMessages) - if (BotReactionsHandler.NeedToSilence(msg).needToChill) - return; - - lastBotMessages = await args.Channel.GetMessagesBeforeCachedAsync(args.Message.Id, Config.ProductCodeLookupHistoryThrottle).ConfigureAwait(false); - StringBuilder? previousRepliesBuilder = null; - foreach (var msg in lastBotMessages) + if (msg.Author.IsCurrent) { - if (msg.Author.IsCurrent) - { - previousRepliesBuilder ??= new(); - previousRepliesBuilder.AppendLine(msg.Content); - var embeds = msg.Embeds; - if (embeds?.Count > 0) - foreach (var embed in embeds) - previousRepliesBuilder.AppendLine(embed.Title).AppendLine(embed.Description); - } - } - var previousReplies = previousRepliesBuilder?.ToString() ?? ""; - var idsFromPreviousReplies = GetIssueIdsFromLinks(previousReplies); - var issuesToLookup = GetIssueIds(args.Message.Content) - .Where(lnk => !idsFromPreviousReplies.Contains(lnk)) - .Take(args.Channel.IsPrivate ? 50 : 5) - .ToList(); - if (issuesToLookup.Count == 0) - return; - - var suffix = issuesToLookup.Count == 1 ? "" : "s"; - if (GithubClient.Client.RateLimitRemaining - issuesToLookup.Count >= 10) - { - foreach (var issueId in issuesToLookup) - await Pr.LinkIssue(c, args.Message, issueId).ConfigureAwait(false); - } - else - { - var result = new StringBuilder($"Link{suffix} to the mentioned issue{suffix}:"); - foreach (var issueId in issuesToLookup) - result.AppendLine().Append("https://github.com/RPCS3/rpcs3/issues/" + issueId); - await args.Channel.SendAutosplitMessageAsync(result, blockStart: null, blockEnd: null).ConfigureAwait(false); + previousRepliesBuilder ??= new(); + previousRepliesBuilder.AppendLine(msg.Content); + var embeds = msg.Embeds; + if (embeds?.Count > 0) + foreach (var embed in embeds) + previousRepliesBuilder.AppendLine(embed.Title).AppendLine(embed.Description); } } + var previousReplies = previousRepliesBuilder?.ToString() ?? ""; + var idsFromPreviousReplies = GetIssueIdsFromLinks(previousReplies); + var issuesToLookup = GetIssueIds(args.Message.Content) + .Where(lnk => !idsFromPreviousReplies.Contains(lnk)) + .Take(args.Channel.IsPrivate ? 50 : 5) + .ToList(); + if (issuesToLookup.Count == 0) + return; - public static List GetIssueIds(string input) + var suffix = issuesToLookup.Count == 1 ? "" : "s"; + if (GithubClient.Client.RateLimitRemaining - issuesToLookup.Count >= 10) { - return IssueMention.Matches(input) - .SelectMany(match => new[] - { - match.Groups["number"].Value, - match.Groups["also_number"].Value, - match.Groups["another_number"].Value, - }) - .Distinct() - .Select(n => int.TryParse(n, out var i) ? i : default) - .Where(n => n > 0) - .ToList(); + foreach (var issueId in issuesToLookup) + await Pr.LinkIssue(c, args.Message, issueId).ConfigureAwait(false); } - public static HashSet GetIssueIdsFromLinks(string input) + else { - return new( - IssueLink.Matches(input) - .Select(match => - { - _ = int.TryParse(match.Groups["number"].Value, out var n); - return n; - }) - ); + var result = new StringBuilder($"Link{suffix} to the mentioned issue{suffix}:"); + foreach (var issueId in issuesToLookup) + result.AppendLine().Append("https://github.com/RPCS3/rpcs3/issues/" + issueId); + await args.Channel.SendAutosplitMessageAsync(result, blockStart: null, blockEnd: null).ConfigureAwait(false); } } -} + + public static List GetIssueIds(string input) + { + return IssueMention.Matches(input) + .SelectMany(match => new[] + { + match.Groups["number"].Value, + match.Groups["also_number"].Value, + match.Groups["another_number"].Value, + }) + .Distinct() + .Select(n => int.TryParse(n, out var i) ? i : default) + .Where(n => n > 0) + .ToList(); + } + public static HashSet GetIssueIdsFromLinks(string input) + { + return new( + IssueLink.Matches(input) + .Select(match => + { + _ = int.TryParse(match.Groups["number"].Value, out var n); + return n; + }) + ); + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/GlobalButtonHandler.cs b/CompatBot/EventHandlers/GlobalButtonHandler.cs index 078d9cc5..e11ce87f 100644 --- a/CompatBot/EventHandlers/GlobalButtonHandler.cs +++ b/CompatBot/EventHandlers/GlobalButtonHandler.cs @@ -4,49 +4,48 @@ using DSharpPlus; using DSharpPlus.CommandsNext; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class GlobalButtonHandler { - internal static class GlobalButtonHandler + public static async Task OnComponentInteraction(DiscordClient sender, ComponentInteractionCreateEventArgs e) { - public static async Task OnComponentInteraction(DiscordClient sender, ComponentInteractionCreateEventArgs e) + if (e.Interaction.Type != InteractionType.Component + || e.Interaction.Data.ComponentType != ComponentType.Button + || e.Interaction.Data.CustomId is not {Length: >0}) + return; + + const string replaceWithUpdatesPrefix = "replace with game updates:"; + var btnId = e.Interaction.Data.CustomId; + if (btnId.StartsWith(replaceWithUpdatesPrefix)) { - if (e.Interaction.Type != InteractionType.Component - || e.Interaction.Data.ComponentType != ComponentType.Button - || e.Interaction.Data.CustomId is not {Length: >0}) - return; - - const string replaceWithUpdatesPrefix = "replace with game updates:"; - var btnId = e.Interaction.Data.CustomId; - if (btnId.StartsWith(replaceWithUpdatesPrefix)) + var parts = btnId.Split(':'); + if (parts.Length != 4) { - var parts = btnId.Split(':'); - if (parts.Length != 4) - { - Config.Log.Warn("Invalid interaction id: " + btnId); - return; - } + Config.Log.Warn("Invalid interaction id: " + btnId); + return; + } - try - { - var authorId = ulong.Parse(parts[1]); - var refMsgId = ulong.Parse(parts[2]); - var productCode = parts[3]; - if (e.User.Id != authorId) - return; + try + { + var authorId = ulong.Parse(parts[1]); + var refMsgId = ulong.Parse(parts[2]); + var productCode = parts[3]; + if (e.User.Id != authorId) + return; - e.Handled = true; - await e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate).ConfigureAwait(false); - await e.Message.DeleteAsync().ConfigureAwait(false); - var refMsg = await e.Channel.GetMessageAsync(refMsgId).ConfigureAwait(false); - var cne = sender.GetCommandsNext(); - var cmd = cne.FindCommand("psn check updates", out _); - var context = cne.CreateContext(refMsg, Config.CommandPrefix, cmd, productCode); - await cne.ExecuteCommandAsync(context).ConfigureAwait(false); - } - catch (Exception ex) - { - Config.Log.Warn(ex); - } + e.Handled = true; + await e.Interaction.CreateResponseAsync(InteractionResponseType.DeferredMessageUpdate).ConfigureAwait(false); + await e.Message.DeleteAsync().ConfigureAwait(false); + var refMsg = await e.Channel.GetMessageAsync(refMsgId).ConfigureAwait(false); + var cne = sender.GetCommandsNext(); + var cmd = cne.FindCommand("psn check updates", out _); + var context = cne.CreateContext(refMsg, Config.CommandPrefix, cmd, productCode); + await cne.ExecuteCommandAsync(context).ConfigureAwait(false); + } + catch (Exception ex) + { + Config.Log.Warn(ex); } } } diff --git a/CompatBot/EventHandlers/GlobalMessageCache.cs b/CompatBot/EventHandlers/GlobalMessageCache.cs index 23d922c2..1fe6eb9f 100644 --- a/CompatBot/EventHandlers/GlobalMessageCache.cs +++ b/CompatBot/EventHandlers/GlobalMessageCache.cs @@ -9,94 +9,123 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using Microsoft.VisualStudio.Services.Common; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +using TCache = ConcurrentDictionary>; + +internal static class GlobalMessageCache { - using TCache = ConcurrentDictionary>; + private static readonly TCache MessageQueue = new(); + private static readonly Func KeyGen = m => m.Id; - internal static class GlobalMessageCache + public static Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) { - private static readonly TCache MessageQueue = new(); - private static readonly Func KeyGen = m => m.Id; - - public static Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) - { - if (args.Channel.IsPrivate) - return Task.CompletedTask; - - if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) - lock (MessageQueue) - { - if (!MessageQueue.TryGetValue(args.Channel.Id, out queue)) - MessageQueue[args.Channel.Id] = queue = new FixedLengthBuffer(KeyGen); - } - lock(queue.SyncObj) - queue.Add(args.Message); - - while (queue.Count > Config.ChannelMessageHistorySize) - lock(queue.SyncObj) - queue.TrimExcess(); + if (args.Channel.IsPrivate) return Task.CompletedTask; - } - public static Task OnMessageDeleted(DiscordClient _, MessageDeleteEventArgs args) - { - if (args.Channel?.IsPrivate ?? true) - return Task.CompletedTask; - - if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) - return Task.CompletedTask; - - lock (queue.SyncObj) - queue.Evict(args.Message.Id); - return Task.CompletedTask; - } - - public static Task OnMessagesBulkDeleted(DiscordClient _, MessageBulkDeleteEventArgs args) - { - if (args.Channel.IsPrivate) - return Task.CompletedTask; - - if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) - return Task.CompletedTask; - - lock (queue.SyncObj) - foreach (var m in args.Messages) - queue.Evict(m.Id); - return Task.CompletedTask; - } - - public static Task OnMessageUpdated(DiscordClient _, MessageUpdateEventArgs args) - { - if (args.Channel.IsPrivate) - return Task.CompletedTask; - - if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) - return Task.CompletedTask; - - lock(queue.SyncObj) - queue.AddOrReplace(args.Message); - return Task.CompletedTask; - } - - internal static Task> GetMessagesCachedAsync(this DiscordChannel ch, int count = 100) - { - if (!MessageQueue.TryGetValue(ch.Id, out var queue)) - lock (MessageQueue) - if (!MessageQueue.TryGetValue(ch.Id, out queue)) - MessageQueue[ch.Id] = queue = new FixedLengthBuffer(KeyGen); - List result; - lock(queue.SyncObj) - result = queue.Reverse().Take(count).ToList(); - var cacheCount = result.Count; - var fetchCount = Math.Max(count - cacheCount, 0); - if (fetchCount > 0) + if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) + lock (MessageQueue) { - IReadOnlyList fetchedList; - if (result.Any()) - fetchedList = ch.GetMessagesBeforeAsync(result[0].Id, fetchCount).ConfigureAwait(false).GetAwaiter().GetResult(); - else - fetchedList = ch.GetMessagesAsync(fetchCount).ConfigureAwait(false).GetAwaiter().GetResult(); - result.AddRange(fetchedList); + if (!MessageQueue.TryGetValue(args.Channel.Id, out queue)) + MessageQueue[args.Channel.Id] = queue = new FixedLengthBuffer(KeyGen); + } + lock(queue.SyncObj) + queue.Add(args.Message); + + while (queue.Count > Config.ChannelMessageHistorySize) + lock(queue.SyncObj) + queue.TrimExcess(); + return Task.CompletedTask; + } + + public static Task OnMessageDeleted(DiscordClient _, MessageDeleteEventArgs args) + { + if (args.Channel?.IsPrivate ?? true) + return Task.CompletedTask; + + if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) + return Task.CompletedTask; + + lock (queue.SyncObj) + queue.Evict(args.Message.Id); + return Task.CompletedTask; + } + + public static Task OnMessagesBulkDeleted(DiscordClient _, MessageBulkDeleteEventArgs args) + { + if (args.Channel.IsPrivate) + return Task.CompletedTask; + + if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) + return Task.CompletedTask; + + lock (queue.SyncObj) + foreach (var m in args.Messages) + queue.Evict(m.Id); + return Task.CompletedTask; + } + + public static Task OnMessageUpdated(DiscordClient _, MessageUpdateEventArgs args) + { + if (args.Channel.IsPrivate) + return Task.CompletedTask; + + if (!MessageQueue.TryGetValue(args.Channel.Id, out var queue)) + return Task.CompletedTask; + + lock(queue.SyncObj) + queue.AddOrReplace(args.Message); + return Task.CompletedTask; + } + + internal static Task> GetMessagesCachedAsync(this DiscordChannel ch, int count = 100) + { + if (!MessageQueue.TryGetValue(ch.Id, out var queue)) + lock (MessageQueue) + if (!MessageQueue.TryGetValue(ch.Id, out queue)) + MessageQueue[ch.Id] = queue = new FixedLengthBuffer(KeyGen); + List result; + lock(queue.SyncObj) + result = queue.Reverse().Take(count).ToList(); + var cacheCount = result.Count; + var fetchCount = Math.Max(count - cacheCount, 0); + if (fetchCount > 0) + { + IReadOnlyList fetchedList; + if (result.Any()) + fetchedList = ch.GetMessagesBeforeAsync(result[0].Id, fetchCount).ConfigureAwait(false).GetAwaiter().GetResult(); + else + fetchedList = ch.GetMessagesAsync(fetchCount).ConfigureAwait(false).GetAwaiter().GetResult(); + result.AddRange(fetchedList); + if (queue.Count < Config.ChannelMessageHistorySize) + lock (queue.SyncObj) + { + // items in queue might've changed since the previous check at the beginning of this method + var freshCopy = queue.Reverse().ToList(); + queue.Clear(); + queue.AddRange(freshCopy.Concat(fetchedList).Distinct().Reverse()); + } + } + return Task.FromResult(result); + } + + internal static Task> GetMessagesBeforeCachedAsync(this DiscordChannel ch, ulong msgId, int count = 100) + { + if (!MessageQueue.TryGetValue(ch.Id, out var queue)) + lock (MessageQueue) + if (!MessageQueue.TryGetValue(ch.Id, out queue)) + MessageQueue[ch.Id] = queue = new FixedLengthBuffer(KeyGen); + List result; + lock(queue.SyncObj) + result = queue.Reverse().SkipWhile(m => m.Id >= msgId).Take(count).ToList(); + var cacheCount = result.Count; + var fetchCount = Math.Max(count - cacheCount, 0); + if (fetchCount > 0) + { + IReadOnlyList fetchedList; + if (result.Any()) + { + fetchedList = ch.GetMessagesBeforeAsync(result[0].Id, fetchCount).ConfigureAwait(false).GetAwaiter().GetResult(); if (queue.Count < Config.ChannelMessageHistorySize) lock (queue.SyncObj) { @@ -106,41 +135,11 @@ namespace CompatBot.EventHandlers queue.AddRange(freshCopy.Concat(fetchedList).Distinct().Reverse()); } } - return Task.FromResult(result); + else + fetchedList = ch.GetMessagesBeforeAsync(msgId, fetchCount).ConfigureAwait(false).GetAwaiter().GetResult(); + result.AddRange(fetchedList); } + return Task.FromResult(result); - internal static Task> GetMessagesBeforeCachedAsync(this DiscordChannel ch, ulong msgId, int count = 100) - { - if (!MessageQueue.TryGetValue(ch.Id, out var queue)) - lock (MessageQueue) - if (!MessageQueue.TryGetValue(ch.Id, out queue)) - MessageQueue[ch.Id] = queue = new FixedLengthBuffer(KeyGen); - List result; - lock(queue.SyncObj) - result = queue.Reverse().SkipWhile(m => m.Id >= msgId).Take(count).ToList(); - var cacheCount = result.Count; - var fetchCount = Math.Max(count - cacheCount, 0); - if (fetchCount > 0) - { - IReadOnlyList fetchedList; - if (result.Any()) - { - fetchedList = ch.GetMessagesBeforeAsync(result[0].Id, fetchCount).ConfigureAwait(false).GetAwaiter().GetResult(); - if (queue.Count < Config.ChannelMessageHistorySize) - lock (queue.SyncObj) - { - // items in queue might've changed since the previous check at the beginning of this method - var freshCopy = queue.Reverse().ToList(); - queue.Clear(); - queue.AddRange(freshCopy.Concat(fetchedList).Distinct().Reverse()); - } - } - else - fetchedList = ch.GetMessagesBeforeAsync(msgId, fetchCount).ConfigureAwait(false).GetAwaiter().GetResult(); - result.AddRange(fetchedList); - } - return Task.FromResult(result); - - } } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/Greeter.cs b/CompatBot/EventHandlers/Greeter.cs index f62fa02e..ef2b6efa 100644 --- a/CompatBot/EventHandlers/Greeter.cs +++ b/CompatBot/EventHandlers/Greeter.cs @@ -5,20 +5,19 @@ using DSharpPlus; using DSharpPlus.EventArgs; using Microsoft.EntityFrameworkCore; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class Greeter { - internal static class Greeter + public static async Task OnMemberAdded(DiscordClient _, GuildMemberAddEventArgs args) { - public static async Task OnMemberAdded(DiscordClient _, GuildMemberAddEventArgs args) + await using var db = new BotDb(); + var explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == "motd").ConfigureAwait(false); + if (explanation != null) { - await using var db = new BotDb(); - var explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == "motd").ConfigureAwait(false); - if (explanation != null) - { - var dm = await args.Member.CreateDmChannelAsync().ConfigureAwait(false); - await dm.SendMessageAsync(explanation.Text, explanation.Attachment, explanation.AttachmentFilename).ConfigureAwait(false); - Config.Log.Info($"Sent motd to {args.Member.GetMentionWithNickname()}"); - } + var dm = await args.Member.CreateDmChannelAsync().ConfigureAwait(false); + await dm.SendMessageAsync(explanation.Text, explanation.Attachment, explanation.AttachmentFilename).ConfigureAwait(false); + Config.Log.Info($"Sent motd to {args.Member.GetMentionWithNickname()}"); } } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/IsTheGamePlayableHandler.cs b/CompatBot/EventHandlers/IsTheGamePlayableHandler.cs index 825bad76..953d7b7f 100644 --- a/CompatBot/EventHandlers/IsTheGamePlayableHandler.cs +++ b/CompatBot/EventHandlers/IsTheGamePlayableHandler.cs @@ -15,25 +15,25 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.EventHandlers -{ - internal static class IsTheGamePlayableHandler - { - private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture; - private static readonly Regex GameNameStatusMention1 = new( - @"(\b((is|does|can I play|any(one|1) tr(y|ied)|how's|(wonder(ing)?|me|knows?) if)\s+)(?.+?)\s+((now|currently|at all|possibly|fully|(on (this|the) )emu(lator))\s+)?((it?s )?playable|work(s|ing)?|runs?|doing))\b" + - @"|(\b(((can I|possible to) (play|run)|any(one|1) tr(y|ied)|compat[ai]bility (with|of))\s+)(?.+?)(\s+((now|currently|at all|possibly|fully)\s+)?((it?s )?playable|work(s|ing)?|on (it|this))\b|\?|$))" + - @"|(^(?.+?)\s+((is )?(playable|work(s|ing)?))\?)", - DefaultOptions - ); - private static readonly ConcurrentDictionary CooldownBuckets = new(); - private static readonly TimeSpan CooldownThreshold = TimeSpan.FromSeconds(5); - private static readonly Client Client = new(); +namespace CompatBot.EventHandlers; - public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) - { - if (DefaultHandlerFilter.IsFluff(args.Message)) - return; +internal static class IsTheGamePlayableHandler +{ + private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture; + private static readonly Regex GameNameStatusMention1 = new( + @"(\b((is|does|can I play|any(one|1) tr(y|ied)|how's|(wonder(ing)?|me|knows?) if)\s+)(?.+?)\s+((now|currently|at all|possibly|fully|(on (this|the) )emu(lator))\s+)?((it?s )?playable|work(s|ing)?|runs?|doing))\b" + + @"|(\b(((can I|possible to) (play|run)|any(one|1) tr(y|ied)|compat[ai]bility (with|of))\s+)(?.+?)(\s+((now|currently|at all|possibly|fully)\s+)?((it?s )?playable|work(s|ing)?|on (it|this))\b|\?|$))" + + @"|(^(?.+?)\s+((is )?(playable|work(s|ing)?))\?)", + DefaultOptions + ); + private static readonly ConcurrentDictionary CooldownBuckets = new(); + private static readonly TimeSpan CooldownThreshold = TimeSpan.FromSeconds(5); + private static readonly Client Client = new(); + + public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) + { + if (DefaultHandlerFilter.IsFluff(args.Message)) + return; #if !DEBUG if (!(args.Channel.Id == Config.BotGeneralChannelId @@ -48,94 +48,93 @@ namespace CompatBot.EventHandlers return; #endif - var matches = GameNameStatusMention1.Matches(args.Message.Content); - if (!matches.Any()) - return; + var matches = GameNameStatusMention1.Matches(args.Message.Content); + if (!matches.Any()) + return; - var gameTitle = matches.Select(m => m.Groups["game_title_1"].Value) - .Concat(matches.Select(m => m.Groups["game_title_2"].Value)) - .Concat(matches.Select(m => m.Groups["game_title_3"].Value)) - .FirstOrDefault(t => !string.IsNullOrEmpty(t)); - if (string.IsNullOrEmpty(gameTitle) || gameTitle.Length < 2) - return; + var gameTitle = matches.Select(m => m.Groups["game_title_1"].Value) + .Concat(matches.Select(m => m.Groups["game_title_2"].Value)) + .Concat(matches.Select(m => m.Groups["game_title_3"].Value)) + .FirstOrDefault(t => !string.IsNullOrEmpty(t)); + if (string.IsNullOrEmpty(gameTitle) || gameTitle.Length < 2) + return; - gameTitle = CompatList.FixGameTitleSearch(gameTitle); - if (gameTitle.Length < 4) - return; + gameTitle = CompatList.FixGameTitleSearch(gameTitle); + if (gameTitle.Length < 4) + return; - if (ProductCodeLookup.ProductCode.IsMatch(args.Message.Content)) - return; + if (ProductCodeLookup.ProductCode.IsMatch(args.Message.Content)) + return; - var (_, info) = await LookupGameAsync(args.Channel, args.Message, gameTitle).ConfigureAwait(false); - if (string.IsNullOrEmpty(info?.Status)) - return; + var (_, info) = await LookupGameAsync(args.Channel, args.Message, gameTitle).ConfigureAwait(false); + if (string.IsNullOrEmpty(info?.Status)) + return; - gameTitle = info.Title?.StripMarks(); - if (string.IsNullOrEmpty(gameTitle)) - return; + gameTitle = info.Title?.StripMarks(); + if (string.IsNullOrEmpty(gameTitle)) + return; - var botSpamChannel = await c.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); - var status = info.Status.ToLowerInvariant(); - string msg; - if (status == "unknown") - msg = $"{args.Message.Author.Mention} {gameTitle} status is {status}"; - else - { - if (status != "playable") - status += " (not playable)"; - msg = $"{args.Message.Author.Mention} {gameTitle} is {status}"; - if (!string.IsNullOrEmpty(info.Date)) - msg += $" since {info.ToUpdated()}"; - } - msg += $"\nfor more results please use compatibility list () or `{Config.CommandPrefix}c` command in {botSpamChannel.Mention} (`!c {gameTitle.Sanitize()}`)"; - await args.Channel.SendMessageAsync(msg).ConfigureAwait(false); - CooldownBuckets[args.Channel.Id] = DateTime.UtcNow; - } - - public static async Task<(string? productCode, TitleInfo? info)> LookupGameAsync(DiscordChannel channel, DiscordMessage message, string gameTitle) + var botSpamChannel = await c.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); + var status = info.Status.ToLowerInvariant(); + string msg; + if (status == "unknown") + msg = $"{args.Message.Author.Mention} {gameTitle} status is {status}"; + else { - var lastBotMessages = await channel.GetMessagesBeforeAsync(message.Id, 20, DateTime.UtcNow.AddSeconds(-30)).ConfigureAwait(false); - foreach (var msg in lastBotMessages) - if (BotReactionsHandler.NeedToSilence(msg).needToChill) - return (null, null); + if (status != "playable") + status += " (not playable)"; + msg = $"{args.Message.Author.Mention} {gameTitle} is {status}"; + if (!string.IsNullOrEmpty(info.Date)) + msg += $" since {info.ToUpdated()}"; + } + msg += $"\nfor more results please use compatibility list () or `{Config.CommandPrefix}c` command in {botSpamChannel.Mention} (`!c {gameTitle.Sanitize()}`)"; + await args.Channel.SendMessageAsync(msg).ConfigureAwait(false); + CooldownBuckets[args.Channel.Id] = DateTime.UtcNow; + } - try - { - var requestBuilder = RequestBuilder.Start().SetSearch(gameTitle); - var searchCompatListTask = Client.GetCompatResultAsync(requestBuilder, Config.Cts.Token); - var localList = CompatList.GetLocalCompatResult(requestBuilder); - var status = await searchCompatListTask.ConfigureAwait(false); - status = status?.Append(localList); - if (status is null - || status.ReturnCode != 0 && status.ReturnCode != 2 - || !status.Results.Any()) - return (null, null); - - var sortedList = status.GetSortedList(); - var bestMatch = sortedList.First(); - var listWithStatus = sortedList - .TakeWhile(i => Math.Abs(i.score - bestMatch.score) < double.Epsilon) - .Where(i => !string.IsNullOrEmpty(i.info.Status) && i.info.Status != "Unknown") - .ToList(); - if (listWithStatus.Count > 0) - bestMatch = listWithStatus.First(); - var (code, info, score) = bestMatch; - Config.Log.Debug($"Looked up \"{gameTitle}\", got \"{info.Title}\" with score {score}"); - if (score < Config.GameTitleMatchThreshold) - return (null, null); - - if (!string.IsNullOrEmpty(info.Title)) - { - StatsStorage.GameStatCache.TryGetValue(info.Title, out int stat); - StatsStorage.GameStatCache.Set(info.Title, ++stat, StatsStorage.CacheTime); - } - return (code, info); - } - catch (Exception e) - { - Config.Log.Warn(e); + public static async Task<(string? productCode, TitleInfo? info)> LookupGameAsync(DiscordChannel channel, DiscordMessage message, string gameTitle) + { + var lastBotMessages = await channel.GetMessagesBeforeAsync(message.Id, 20, DateTime.UtcNow.AddSeconds(-30)).ConfigureAwait(false); + foreach (var msg in lastBotMessages) + if (BotReactionsHandler.NeedToSilence(msg).needToChill) return (null, null); + + try + { + var requestBuilder = RequestBuilder.Start().SetSearch(gameTitle); + var searchCompatListTask = Client.GetCompatResultAsync(requestBuilder, Config.Cts.Token); + var localList = CompatList.GetLocalCompatResult(requestBuilder); + var status = await searchCompatListTask.ConfigureAwait(false); + status = status?.Append(localList); + if (status is null + || status.ReturnCode != 0 && status.ReturnCode != 2 + || !status.Results.Any()) + return (null, null); + + var sortedList = status.GetSortedList(); + var bestMatch = sortedList.First(); + var listWithStatus = sortedList + .TakeWhile(i => Math.Abs(i.score - bestMatch.score) < double.Epsilon) + .Where(i => !string.IsNullOrEmpty(i.info.Status) && i.info.Status != "Unknown") + .ToList(); + if (listWithStatus.Count > 0) + bestMatch = listWithStatus.First(); + var (code, info, score) = bestMatch; + Config.Log.Debug($"Looked up \"{gameTitle}\", got \"{info.Title}\" with score {score}"); + if (score < Config.GameTitleMatchThreshold) + return (null, null); + + if (!string.IsNullOrEmpty(info.Title)) + { + StatsStorage.GameStatCache.TryGetValue(info.Title, out int stat); + StatsStorage.GameStatCache.Set(info.Title, ++stat, StatsStorage.CacheTime); } + return (code, info); + } + catch (Exception e) + { + Config.Log.Warn(e); + return (null, null); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogAsTextMonitor.cs b/CompatBot/EventHandlers/LogAsTextMonitor.cs index f4853dbb..646e0742 100644 --- a/CompatBot/EventHandlers/LogAsTextMonitor.cs +++ b/CompatBot/EventHandlers/LogAsTextMonitor.cs @@ -7,47 +7,46 @@ using DSharpPlus; using DSharpPlus.Entities; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class LogAsTextMonitor { - internal static class LogAsTextMonitor + private static readonly Regex LogLine = new(@"^[`""]?(·|(\w|!)) ({(rsx|PPU|SPU)|LDR:)|E LDR:", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline); + + public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) { - private static readonly Regex LogLine = new(@"^[`""]?(·|(\w|!)) ({(rsx|PPU|SPU)|LDR:)|E LDR:", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline); + if (DefaultHandlerFilter.IsFluff(args.Message)) + return; - public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) + if (!"help".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) + return; + + if ((args.Message.Author as DiscordMember)?.Roles.Any() ?? false) + return; + + if (LogLine.IsMatch(args.Message.Content)) { - if (DefaultHandlerFilter.IsFluff(args.Message)) - return; - - if (!"help".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) - return; - - if ((args.Message.Author as DiscordMember)?.Roles.Any() ?? false) - return; - - if (LogLine.IsMatch(args.Message.Content)) + var brokenDump = false; + string msg = ""; + if (args.Message.Content.Contains("LDR:")) { - var brokenDump = false; - string msg = ""; - if (args.Message.Content.Contains("LDR:")) - { - brokenDump = true; - if (args.Message.Content.Contains("fs::file is null")) - msg = $"{args.Message.Author.Mention} this error usually indicates a missing `.rap` license file.\n"; - else if (args.Message.Content.Contains("Invalid or unsupported file format")) - msg = $"{args.Message.Author.Mention} this error usually indicates an encrypted or corrupted game dump.\n"; - else - brokenDump = false; - } - var logUploadExplain = await PostLogHelpHandler.GetExplanationAsync("log").ConfigureAwait(false); - if (brokenDump) - msg += "Please follow the quickstart guide to get a proper dump of a digital title.\n" + - "Also please upload the full RPCS3 log instead of pasting only a section which may be completely irrelevant.\n" + - logUploadExplain.Text; + brokenDump = true; + if (args.Message.Content.Contains("fs::file is null")) + msg = $"{args.Message.Author.Mention} this error usually indicates a missing `.rap` license file.\n"; + else if (args.Message.Content.Contains("Invalid or unsupported file format")) + msg = $"{args.Message.Author.Mention} this error usually indicates an encrypted or corrupted game dump.\n"; else - msg = $"{args.Message.Author.Mention} please upload the full RPCS3 log instead of pasting only a section which may be completely irrelevant." + - logUploadExplain.Text; - await args.Channel.SendMessageAsync(msg, logUploadExplain.Attachment, logUploadExplain.AttachmentFilename).ConfigureAwait(false); + brokenDump = false; } + var logUploadExplain = await PostLogHelpHandler.GetExplanationAsync("log").ConfigureAwait(false); + if (brokenDump) + msg += "Please follow the quickstart guide to get a proper dump of a digital title.\n" + + "Also please upload the full RPCS3 log instead of pasting only a section which may be completely irrelevant.\n" + + logUploadExplain.Text; + else + msg = $"{args.Message.Author.Mention} please upload the full RPCS3 log instead of pasting only a section which may be completely irrelevant." + + logUploadExplain.Text; + await args.Channel.SendMessageAsync(msg, logUploadExplain.Attachment, logUploadExplain.AttachmentFilename).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/GzipHandler.cs b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/GzipHandler.cs index 1126fd63..8d0fcef5 100644 --- a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/GzipHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/GzipHandler.cs @@ -6,57 +6,56 @@ using System.Threading; using System.Threading.Tasks; using CompatBot.Utils; -namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers +namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers; + +internal sealed class GzipHandler: IArchiveHandler { - internal sealed class GzipHandler: IArchiveHandler + private static readonly byte[] Header = { 0x1F, 0x8B, 0x08 }; + + public long LogSize { get; private set; } + public long SourcePosition { get; private set; } + + public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) { - private static readonly byte[] Header = { 0x1F, 0x8B, 0x08 }; - - public long LogSize { get; private set; } - public long SourcePosition { get; private set; } - - public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) + if (header.Length >= Header.Length) { - if (header.Length >= Header.Length) - { - if (header.Slice(0, Header.Length).SequenceEqual(Header)) - return (true, null); - } - else if (fileName.EndsWith(".log.gz", StringComparison.InvariantCultureIgnoreCase) - && !fileName.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) + if (header.Slice(0, Header.Length).SequenceEqual(Header)) return (true, null); - - return (false, null); } + else if (fileName.EndsWith(".log.gz", StringComparison.InvariantCultureIgnoreCase) + && !fileName.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) + return (true, null); - public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + return (false, null); + } + + public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + { + await using var statsStream = new BufferCopyStream(sourceStream); + await using var gzipStream = new GZipStream(statsStream, CompressionMode.Decompress); + try { - await using var statsStream = new BufferCopyStream(sourceStream); - await using var gzipStream = new GZipStream(statsStream, CompressionMode.Decompress); - try + int read; + FlushResult flushed; + do { - int read; - FlushResult flushed; - do - { - var memory = writer.GetMemory(Config.MinimumBufferSize); - read = await gzipStream.ReadAsync(memory, cancellationToken); - writer.Advance(read); - SourcePosition = statsStream.Position; - flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); - } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); + var memory = writer.GetMemory(Config.MinimumBufferSize); + read = await gzipStream.ReadAsync(memory, cancellationToken); + writer.Advance(read); + SourcePosition = statsStream.Position; + flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); - var buf = statsStream.GetBufferedBytes(); - if (buf.Length > 3) - LogSize = BitConverter.ToInt32(buf.AsSpan(buf.Length - 4, 4)); - } - catch (Exception e) - { - Config.Log.Error(e, "Error filling the log pipe"); - await writer.CompleteAsync(e); - return; - } - await writer.CompleteAsync(); + var buf = statsStream.GetBufferedBytes(); + if (buf.Length > 3) + LogSize = BitConverter.ToInt32(buf.AsSpan(buf.Length - 4, 4)); } + catch (Exception e) + { + Config.Log.Error(e, "Error filling the log pipe"); + await writer.CompleteAsync(e); + return; + } + await writer.CompleteAsync(); } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/IArchiveHandler.cs b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/IArchiveHandler.cs index feacbc2e..e414d40b 100644 --- a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/IArchiveHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/IArchiveHandler.cs @@ -4,13 +4,12 @@ using System.IO.Pipelines; using System.Threading; using System.Threading.Tasks; -namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers +namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers; + +public interface IArchiveHandler { - public interface IArchiveHandler - { - (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header); - Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken); - long LogSize { get; } - long SourcePosition { get; } - } + (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header); + Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken); + long LogSize { get; } + long SourcePosition { get; } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/PlainText.cs b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/PlainText.cs index a908445c..a8d44323 100644 --- a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/PlainText.cs +++ b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/PlainText.cs @@ -5,46 +5,45 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers +namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers; + +internal sealed class PlainTextHandler: IArchiveHandler { - internal sealed class PlainTextHandler: IArchiveHandler + public long LogSize { get; private set; } + public long SourcePosition { get; private set; } + + public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) { - public long LogSize { get; private set; } - public long SourcePosition { get; private set; } - - public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) - { - LogSize = fileSize; - if (fileName.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) - return (false, null); - - if (header.Length > 10 && Encoding.UTF8.GetString(header.Slice(0, 30)).Contains("RPCS3 v")) - return (true, null); - + LogSize = fileSize; + if (fileName.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) return (false, null); - } - public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) - { - try - { - int read; - FlushResult flushed; - do - { - var memory = writer.GetMemory(Config.MinimumBufferSize); - read = await sourceStream.ReadAsync(memory, cancellationToken); - writer.Advance(read); - flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); - } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); - } - catch (Exception e) - { - Config.Log.Error(e, "Error filling the log pipe"); - await writer.CompleteAsync(e); - return; - } - await writer.CompleteAsync(); - } + if (header.Length > 10 && Encoding.UTF8.GetString(header.Slice(0, 30)).Contains("RPCS3 v")) + return (true, null); + + return (false, null); } -} + + public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + { + try + { + int read; + FlushResult flushed; + do + { + var memory = writer.GetMemory(Config.MinimumBufferSize); + read = await sourceStream.ReadAsync(memory, cancellationToken); + writer.Advance(read); + flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); + } + catch (Exception e) + { + Config.Log.Error(e, "Error filling the log pipe"); + await writer.CompleteAsync(e); + return; + } + await writer.CompleteAsync(); + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/RarHandler.cs b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/RarHandler.cs index 771bee05..299f4938 100644 --- a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/RarHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/RarHandler.cs @@ -7,67 +7,66 @@ using System.Threading.Tasks; using CompatBot.Utils; using SharpCompress.Readers.Rar; -namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers +namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers; + +internal sealed class RarHandler: IArchiveHandler { - internal sealed class RarHandler: IArchiveHandler + private static readonly byte[] Header = {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07}; + + public long LogSize { get; private set; } + public long SourcePosition { get; private set; } + + public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) { - private static readonly byte[] Header = {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07}; - - public long LogSize { get; private set; } - public long SourcePosition { get; private set; } - - public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) + if (header.Length >= Header.Length && header.Slice(0, Header.Length).SequenceEqual(Header) + || header.Length == 0 && fileName.EndsWith(".rar", StringComparison.InvariantCultureIgnoreCase)) { - if (header.Length >= Header.Length && header.Slice(0, Header.Length).SequenceEqual(Header) - || header.Length == 0 && fileName.EndsWith(".rar", StringComparison.InvariantCultureIgnoreCase)) - { - var firstEntry = Encoding.ASCII.GetString(header); - if (!firstEntry.Contains(".log", StringComparison.InvariantCultureIgnoreCase)) - return (false, "Archive doesn't contain any logs."); + var firstEntry = Encoding.ASCII.GetString(header); + if (!firstEntry.Contains(".log", StringComparison.InvariantCultureIgnoreCase)) + return (false, "Archive doesn't contain any logs."); - return (true, null); - } - - return (false, null); + return (true, null); } - public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + return (false, null); + } + + public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + { + try { - try + await using var statsStream = new BufferCopyStream(sourceStream); + using var rarReader = RarReader.Open(statsStream); + while (rarReader.MoveToNextEntry()) { - await using var statsStream = new BufferCopyStream(sourceStream); - using var rarReader = RarReader.Open(statsStream); - while (rarReader.MoveToNextEntry()) + if (!rarReader.Entry.IsDirectory + && rarReader.Entry.Key.EndsWith(".log", StringComparison.InvariantCultureIgnoreCase) + && !rarReader.Entry.Key.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) { - if (!rarReader.Entry.IsDirectory - && rarReader.Entry.Key.EndsWith(".log", StringComparison.InvariantCultureIgnoreCase) - && !rarReader.Entry.Key.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) + LogSize = rarReader.Entry.Size; + await using var rarStream = rarReader.OpenEntryStream(); + int read; + FlushResult flushed; + do { - LogSize = rarReader.Entry.Size; - await using var rarStream = rarReader.OpenEntryStream(); - int read; - FlushResult flushed; - do - { - var memory = writer.GetMemory(Config.MinimumBufferSize); - read = await rarStream.ReadAsync(memory, cancellationToken); - writer.Advance(read); - SourcePosition = statsStream.Position; - flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); - SourcePosition = statsStream.Position; - } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); - await writer.CompleteAsync(); - return; - } - SourcePosition = statsStream.Position; + var memory = writer.GetMemory(Config.MinimumBufferSize); + read = await rarStream.ReadAsync(memory, cancellationToken); + writer.Advance(read); + SourcePosition = statsStream.Position; + flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + SourcePosition = statsStream.Position; + } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); + await writer.CompleteAsync(); + return; } - Config.Log.Warn("No rar entries that match the log criteria"); + SourcePosition = statsStream.Position; } - catch (Exception e) - { - Config.Log.Error(e, "Error filling the log pipe"); - } - await writer.CompleteAsync(); + Config.Log.Warn("No rar entries that match the log criteria"); } + catch (Exception e) + { + Config.Log.Error(e, "Error filling the log pipe"); + } + await writer.CompleteAsync(); } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/SevenZipHandler.cs b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/SevenZipHandler.cs index af363658..0686d18c 100644 --- a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/SevenZipHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/SevenZipHandler.cs @@ -6,64 +6,63 @@ using System.Threading.Tasks; using CompatApiClient.Utils; using SharpCompress.Archives.SevenZip; -namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers +namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers; + +internal sealed class SevenZipHandler: IArchiveHandler { - internal sealed class SevenZipHandler: IArchiveHandler + private static readonly byte[] Header = {0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C}; + + public long LogSize { get; private set; } + public long SourcePosition { get; private set; } + + public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) { - private static readonly byte[] Header = {0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C}; - - public long LogSize { get; private set; } - public long SourcePosition { get; private set; } - - public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) + if (header.Length >= Header.Length && header.Slice(0, Header.Length).SequenceEqual(Header) + || header.Length == 0 && fileName.EndsWith(".7z", StringComparison.InvariantCultureIgnoreCase)) { - if (header.Length >= Header.Length && header.Slice(0, Header.Length).SequenceEqual(Header) - || header.Length == 0 && fileName.EndsWith(".7z", StringComparison.InvariantCultureIgnoreCase)) - { - if (fileSize > Config.AttachmentSizeLimit) - return (false, $"Log size is too large for 7z format: {fileSize.AsStorageUnit()} (max allowed is {Config.AttachmentSizeLimit.AsStorageUnit()})"); + if (fileSize > Config.AttachmentSizeLimit) + return (false, $"Log size is too large for 7z format: {fileSize.AsStorageUnit()} (max allowed is {Config.AttachmentSizeLimit.AsStorageUnit()})"); - return (true, null); - } - - return (false, null); + return (true, null); } - public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + return (false, null); + } + + public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + { + try { - try - { - await using var fileStream = new FileStream(Path.GetTempFileName(), FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 16384, FileOptions.Asynchronous | FileOptions.RandomAccess | FileOptions.DeleteOnClose); - await sourceStream.CopyToAsync(fileStream, 16384, cancellationToken).ConfigureAwait(false); - fileStream.Seek(0, SeekOrigin.Begin); - using var zipArchive = SevenZipArchive.Open(fileStream); - using var zipReader = zipArchive.ExtractAllEntries(); - while (zipReader.MoveToNextEntry()) - if (!zipReader.Entry.IsDirectory - && zipReader.Entry.Key.EndsWith(".log", StringComparison.InvariantCultureIgnoreCase) - && !zipReader.Entry.Key.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) + await using var fileStream = new FileStream(Path.GetTempFileName(), FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 16384, FileOptions.Asynchronous | FileOptions.RandomAccess | FileOptions.DeleteOnClose); + await sourceStream.CopyToAsync(fileStream, 16384, cancellationToken).ConfigureAwait(false); + fileStream.Seek(0, SeekOrigin.Begin); + using var zipArchive = SevenZipArchive.Open(fileStream); + using var zipReader = zipArchive.ExtractAllEntries(); + while (zipReader.MoveToNextEntry()) + if (!zipReader.Entry.IsDirectory + && zipReader.Entry.Key.EndsWith(".log", StringComparison.InvariantCultureIgnoreCase) + && !zipReader.Entry.Key.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) + { + LogSize = zipReader.Entry.Size; + await using var entryStream = zipReader.OpenEntryStream(); + int read; + FlushResult flushed; + do { - LogSize = zipReader.Entry.Size; - await using var entryStream = zipReader.OpenEntryStream(); - int read; - FlushResult flushed; - do - { - var memory = writer.GetMemory(Config.MinimumBufferSize); - read = await entryStream.ReadAsync(memory, cancellationToken); - writer.Advance(read); - flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); - } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); - await writer.CompleteAsync(); - return; - } - Config.Log.Warn("No 7z entries that match the log criteria"); - } - catch (Exception e) - { - Config.Log.Error(e, "Error filling the log pipe"); - } - await writer.CompleteAsync(); + var memory = writer.GetMemory(Config.MinimumBufferSize); + read = await entryStream.ReadAsync(memory, cancellationToken); + writer.Advance(read); + flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); + await writer.CompleteAsync(); + return; + } + Config.Log.Warn("No 7z entries that match the log criteria"); } + catch (Exception e) + { + Config.Log.Error(e, "Error filling the log pipe"); + } + await writer.CompleteAsync(); } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/ZipHandler.cs b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/ZipHandler.cs index 10a435f6..e936a74d 100644 --- a/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/ZipHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/ArchiveHandlers/ZipHandler.cs @@ -7,68 +7,67 @@ using System.Threading.Tasks; using CompatBot.Utils; using SharpCompress.Readers.Zip; -namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers +namespace CompatBot.EventHandlers.LogParsing.ArchiveHandlers; + +internal sealed class ZipHandler: IArchiveHandler { - internal sealed class ZipHandler: IArchiveHandler + private static readonly byte[] Header = { 0x50, 0x4B, 0x03, 0x04 }; + + public long LogSize { get; private set; } + public long SourcePosition { get; private set; } + + public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) { - private static readonly byte[] Header = { 0x50, 0x4B, 0x03, 0x04 }; - public long LogSize { get; private set; } - public long SourcePosition { get; private set; } - - public (bool result, string? reason) CanHandle(string fileName, int fileSize, ReadOnlySpan header) + if (header.Length >= Header.Length && header.Slice(0, Header.Length).SequenceEqual(Header) + || header.Length == 0 && fileName.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) { + var firstEntry = Encoding.ASCII.GetString(header); + if (!firstEntry.Contains(".log", StringComparison.InvariantCultureIgnoreCase)) + return (false, "Archive doesn't contain any logs."); - if (header.Length >= Header.Length && header.Slice(0, Header.Length).SequenceEqual(Header) - || header.Length == 0 && fileName.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) - { - var firstEntry = Encoding.ASCII.GetString(header); - if (!firstEntry.Contains(".log", StringComparison.InvariantCultureIgnoreCase)) - return (false, "Archive doesn't contain any logs."); - - return (true, null); - } - - return (false, null); + return (true, null); } - public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + return (false, null); + } + + public async Task FillPipeAsync(Stream sourceStream, PipeWriter writer, CancellationToken cancellationToken) + { + try { - try + await using var statsStream = new BufferCopyStream(sourceStream); + using var zipReader = ZipReader.Open(statsStream); + while (zipReader.MoveToNextEntry()) { - await using var statsStream = new BufferCopyStream(sourceStream); - using var zipReader = ZipReader.Open(statsStream); - while (zipReader.MoveToNextEntry()) + if (!zipReader.Entry.IsDirectory + && zipReader.Entry.Key.EndsWith(".log", StringComparison.InvariantCultureIgnoreCase) + && !zipReader.Entry.Key.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) { - if (!zipReader.Entry.IsDirectory - && zipReader.Entry.Key.EndsWith(".log", StringComparison.InvariantCultureIgnoreCase) - && !zipReader.Entry.Key.Contains("tty.log", StringComparison.InvariantCultureIgnoreCase)) + LogSize = zipReader.Entry.Size; + await using var rarStream = zipReader.OpenEntryStream(); + int read; + FlushResult flushed; + do { - LogSize = zipReader.Entry.Size; - await using var rarStream = zipReader.OpenEntryStream(); - int read; - FlushResult flushed; - do - { - var memory = writer.GetMemory(Config.MinimumBufferSize); - read = await rarStream.ReadAsync(memory, cancellationToken); - writer.Advance(read); - SourcePosition = statsStream.Position; - flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); - SourcePosition = statsStream.Position; - } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); - await writer.CompleteAsync(); - return; - } - SourcePosition = statsStream.Position; + var memory = writer.GetMemory(Config.MinimumBufferSize); + read = await rarStream.ReadAsync(memory, cancellationToken); + writer.Advance(read); + SourcePosition = statsStream.Position; + flushed = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + SourcePosition = statsStream.Position; + } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || cancellationToken.IsCancellationRequested)); + await writer.CompleteAsync(); + return; } - Config.Log.Warn("No rar entries that match the log criteria"); + SourcePosition = statsStream.Position; } - catch (Exception e) - { - Config.Log.Error(e, "Error filling the log pipe"); - } - await writer.CompleteAsync(); + Config.Log.Warn("No rar entries that match the log criteria"); } + catch (Exception e) + { + Config.Log.Error(e, "Error filling the log pipe"); + } + await writer.CompleteAsync(); } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/LogParser.LogSections.cs b/CompatBot/EventHandlers/LogParsing/LogParser.LogSections.cs index 94bffb7f..d9ff5d19 100644 --- a/CompatBot/EventHandlers/LogParsing/LogParser.LogSections.cs +++ b/CompatBot/EventHandlers/LogParsing/LogParser.LogSections.cs @@ -7,341 +7,340 @@ using CompatBot.EventHandlers.LogParsing.POCOs; using CompatBot.Utils; using CompatBot.Utils.ResultFormatters; -namespace CompatBot.EventHandlers.LogParsing +namespace CompatBot.EventHandlers.LogParsing; + +internal partial class LogParser { - internal partial class LogParser + private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture; + private const RegexOptions DefaultSingleLineOptions = RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.ExplicitCapture; + + /* + * Extractors are defined in terms of trigger-extractor + * + * Parser scans the log from section to section with a sliding window of up to 50 lines of text + * Triggers are scanned for in the first line of said sliding window + * If trigger is matched, then the associated regex will be run on THE WHOLE sliding window + * If any data was captured, it will be stored in the current collection of items with the key of the explicit capture group of regex + * + */ + private static readonly List LogSections = new() { - private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture; - private const RegexOptions DefaultSingleLineOptions = RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.ExplicitCapture; - - /* - * Extractors are defined in terms of trigger-extractor - * - * Parser scans the log from section to section with a sliding window of up to 50 lines of text - * Triggers are scanned for in the first line of said sliding window - * If trigger is matched, then the associated regex will be run on THE WHOLE sliding window - * If any data was captured, it will be stored in the current collection of items with the key of the explicit capture group of regex - * - */ - private static readonly List LogSections = new() + new() { - new() + Extractors = new() { - Extractors = new() - { - ["RPCS3 v"] = new(@"(^|.+\d:\d\d:\d\d\.\d{6})\s*(?RPCS3 [^\xC2\xB7]+?)\r?(\n·|$)", DefaultSingleLineOptions), - ["0:00:00.0"] = new(@"(?·).+\r?$", DefaultOptions), - ["Operating system:"] = LogParserResult.OsInfoInLog, - ["Current Time:"] = new(@"Current Time: (?.+)\r?$", DefaultOptions), - ["Physical device intialized"] = new(@"Physical device intialized\. GPU=(?.+), driver=(?-?\d+)\r?$", DefaultOptions), - ["Found vulkan-compatible GPU:"] = new(@"Found vulkan-compatible GPU: (?'(?.+)' running.+)\r?$", DefaultOptions), - ["Finished reading database from file:"] = new(@"Finished reading database from file: (?.*compat_database.dat).*\r?$", DefaultOptions), - ["Database file not found:"] = new(@"Database file not found: (?.*compat_database.dat).*\r?$", DefaultOptions), - ["Successfully installed PS3 firmware"] = new(@"(?Successfully installed PS3 firmware) version (?\d+\.\d+).*\r?$", DefaultOptions), - ["Firmware version:"] = new(@"Firmware version: (?\d+\.\d+).*\r?$", DefaultOptions), - ["Title:"] = new(@"(?:LDR|SYS): Title: (?.*)?\r?$", DefaultOptions), - ["Serial:"] = new(@"Serial: (?[A-z]{4}\d{5})\r?$", DefaultOptions), - ["Category:"] = new(@"Category: (?.*)?\r?$", DefaultOptions), - ["LDR: Version:"] = new(@"Version: (?\S+) / (?\S+).*?\r?$", DefaultOptions), - ["SYS: Version:"] = new(@"Version: (APP_VER=)?(?\S+) (/ |VERSION=)(?\S+).*?\r?$", DefaultOptions), - ["LDR: Cache"] = new(@"Cache: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), - ["SYS: Cache"] = new(@"Cache: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), - ["LDR: Path"] = new(@"Path: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), - ["SYS: Path"] = new(@"Path: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), - ["LDR: Path:"] = new(@"Path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["SYS: Path:"] = new(@"Path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["custom config:"] = new(@"custom config: (?.*?)\r?$", DefaultOptions), - ["patch_log: Failed to load patch file"] = new(@"patch_log: Failed to load patch file (?\S*)\r?\n.* line (?\d+), column (?\d+): (?.*?)$", DefaultOptions), - }, - EndTrigger = new[] {"Used configuration:"}, + ["RPCS3 v"] = new(@"(^|.+\d:\d\d:\d\d\.\d{6})\s*(?RPCS3 [^\xC2\xB7]+?)\r?(\n·|$)", DefaultSingleLineOptions), + ["0:00:00.0"] = new(@"(?·).+\r?$", DefaultOptions), + ["Operating system:"] = LogParserResult.OsInfoInLog, + ["Current Time:"] = new(@"Current Time: (?.+)\r?$", DefaultOptions), + ["Physical device intialized"] = new(@"Physical device intialized\. GPU=(?.+), driver=(?-?\d+)\r?$", DefaultOptions), + ["Found vulkan-compatible GPU:"] = new(@"Found vulkan-compatible GPU: (?'(?.+)' running.+)\r?$", DefaultOptions), + ["Finished reading database from file:"] = new(@"Finished reading database from file: (?.*compat_database.dat).*\r?$", DefaultOptions), + ["Database file not found:"] = new(@"Database file not found: (?.*compat_database.dat).*\r?$", DefaultOptions), + ["Successfully installed PS3 firmware"] = new(@"(?Successfully installed PS3 firmware) version (?\d+\.\d+).*\r?$", DefaultOptions), + ["Firmware version:"] = new(@"Firmware version: (?\d+\.\d+).*\r?$", DefaultOptions), + ["Title:"] = new(@"(?:LDR|SYS): Title: (?.*)?\r?$", DefaultOptions), + ["Serial:"] = new(@"Serial: (?[A-z]{4}\d{5})\r?$", DefaultOptions), + ["Category:"] = new(@"Category: (?.*)?\r?$", DefaultOptions), + ["LDR: Version:"] = new(@"Version: (?\S+) / (?\S+).*?\r?$", DefaultOptions), + ["SYS: Version:"] = new(@"Version: (APP_VER=)?(?\S+) (/ |VERSION=)(?\S+).*?\r?$", DefaultOptions), + ["LDR: Cache"] = new(@"Cache: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), + ["SYS: Cache"] = new(@"Cache: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), + ["LDR: Path"] = new(@"Path: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), + ["SYS: Path"] = new(@"Path: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), + ["LDR: Path:"] = new(@"Path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["SYS: Path:"] = new(@"Path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["custom config:"] = new(@"custom config: (?.*?)\r?$", DefaultOptions), + ["patch_log: Failed to load patch file"] = new(@"patch_log: Failed to load patch file (?\S*)\r?\n.* line (?\d+), column (?\d+): (?.*?)$", DefaultOptions), }, - new() - { - Extractors = new() - { - ["PPU Decoder:"] = new(@"PPU Decoder: (?.*?)\r?$", DefaultOptions), - ["PPU Threads:"] = new(@"PPU Threads: (?.*?)\r?$", DefaultOptions), - ["Use LLVM CPU:"] = new("Use LLVM CPU: \\\"?(?.*?)\\\"?\r?$", DefaultOptions), - ["thread scheduler"] = new(@"[Ss]cheduler( Mode)?: (?.*?)\r?$", DefaultOptions), - ["SPU Decoder:"] = new(@"SPU Decoder: (?.*?)\r?$", DefaultOptions), - ["secondary cores:"] = new(@"secondary cores: (?.*?)\r?$", DefaultOptions), - //["priority:"] = new(@"priority: (?.*?)\r?$", DefaultOptions), - ["SPU Threads:"] = new(@"SPU Threads: (?.*?)\r?$", DefaultOptions), - ["SPU delay penalty:"] = new(@"SPU delay penalty: (?.*?)\r?$", DefaultOptions), - ["SPU loop detection:"] = new(@"SPU loop detection: (?.*?)\r?$", DefaultOptions), - ["Max SPURS Threads:"] = new(@"Max SPURS Threads: (?\d*?)\r?$", DefaultOptions), - ["SPU Block Size:"] = new(@"SPU Block Size: (?.*?)\r?$", DefaultOptions), - ["Enable TSX:"] = new(@"Enable TSX: (?.*?)\r?$", DefaultOptions), - ["Accurate xfloat:"] = new(@"Accurate xfloat: (?.*?)\r?$", DefaultOptions), - ["Accurate GETLLAR:"] = new(@"Accurate GETLLAR: (?.*?)\r?$", DefaultOptions), - ["Accurate PUTLLUC:"] = new(@"Accurate PUTLLUC: (?.*?)\r?$", DefaultOptions), - ["Accurate RSX reservation access:"] = new(@"Accurate RSX reservation access: (?.*?)\r?$", DefaultOptions), - ["Approximate xfloat:"] = new(@"Approximate xfloat: (?.*?)\r?$", DefaultOptions), - ["Debug Console Mode:"] = new(@"Debug Console Mode: (?.*?)\r?$", DefaultOptions), - ["Lib Loader:"] = new(@"[Ll]oader: (?.*?)\r?$", DefaultOptions), - ["Hook static functions:"] = new(@"Hook static functions: (?.*?)\r?$", DefaultOptions), - ["Load libraries:"] = new(@"libraries:\r?\n(?(.*?(- .*?|\[\])\r?\n)+)", DefaultOptions), - ["Libraries Control:"] = new(@"Libraries Control:\r?\n(?(.*?(- .*?|\[\])\r?\n)+)", DefaultOptions), - ["HLE lwmutex:"] = new(@"HLE lwmutex: (?.*?)\r?$", DefaultOptions), - ["Clocks scale:"] = new(@"Clocks scale: (?.*?)\r?$", DefaultOptions), - ["Sleep Timers Accuracy:"] = new(@"Sleep Timers Accuracy: (?.*?)\r?$", DefaultOptions), - }, - EndTrigger = new[] {"VFS:"}, - }, - new() - { - Extractors = new() - { - ["Enable /host_root/:"] = new(@"Enable /host_root/: (?.*?)\r?$", DefaultOptions), - }, - EndTrigger = new[] {"Video:"}, - }, - new() - { - Extractors = new() - { - ["Renderer:"] = new("Renderer: (?.*?)\r?$", DefaultOptions), - ["Resolution:"] = new("Resolution: (?.*?)\r?$", DefaultOptions), - ["Aspect ratio:"] = new("Aspect ratio: (?.*?)\r?$", DefaultOptions), - ["Frame limit:"] = new("Frame limit: (?.*?)\r?$", DefaultOptions), - ["MSAA:"] = new("MSAA: (?.*?)\r?$", DefaultOptions), - ["Write Color Buffers:"] = new("Write Color Buffers: (?.*?)\r?$", DefaultOptions), - ["Write Depth Buffer:"] = new("Write Depth Buffer: (?.*?)\r?$", DefaultOptions), - ["Read Color Buffers:"] = new("Read Color Buffers: (?.*?)\r?$", DefaultOptions), - ["Read Depth Buffer:"] = new("Read Depth Buffer: (?.*?)\r?$", DefaultOptions), - ["VSync:"] = new("VSync: (?.*?)\r?$", DefaultOptions), - ["GPU texture scaling:"] = new("Use GPU texture scaling: (?.*?)\r?$", DefaultOptions), - ["Stretch To Display Area:"] = new("Stretch To Display Area: (?.*?)\r?$", DefaultOptions), - ["Strict Rendering Mode:"] = new("Strict Rendering Mode: (?.*?)\r?$", DefaultOptions), - ["Occlusion Queries:"] = new("Occlusion Queries: (?.*?)\r?$", DefaultOptions), - ["Vertex Cache:"] = new("Disable Vertex Cache: (?.*?)\r?$", DefaultOptions), - ["Frame Skip:"] = new("Enable Frame Skip: (?.*?)\r?$", DefaultOptions), - ["Blit:"] = new("Blit: (?.*?)\r?$", DefaultOptions), - ["Disable Asynchronous Shader Compiler:"] = new("Disable Asynchronous Shader Compiler: (?.*?)\r?$", DefaultOptions), - ["Shader Mode:"] = new("Shader Mode: (?.*?)\r?$", DefaultOptions), - ["Disable native float16 support:"] = new("Disable native float16 support: (?.*?)\r?$", DefaultOptions), - ["Multithreaded RSX:"] = new("Multithreaded RSX: (?.*?)\r?$", DefaultOptions), - ["Relaxed ZCULL Sync:"] = new("Relaxed ZCULL Sync: (?.*?)\r?$", DefaultOptions), - ["Resolution Scale:"] = new("Resolution Scale: (?.*?)\r?$", DefaultOptions), - ["Anisotropic Filter"] = new("Anisotropic Filter Override: (?.*?)\r?$", DefaultOptions), - ["Scalable Dimension:"] = new("Minimum Scalable Dimension: (?.*?)\r?$", DefaultOptions), - ["Driver Recovery Timeout:"] = new("Driver Recovery Timeout: (?.*?)\r?$", DefaultOptions), - ["Driver Wake-Up Delay:"] = new("Driver Wake-Up Delay: (?.*?)\r?$", DefaultOptions), - ["Vblank Rate:"] = new("Vblank Rate: (?.*?)\r?$", DefaultOptions), - ["12:"] = new(@"(D3D12|DirectX 12):\s*\r?\n\s*Adapter: (?.*?)\r?$", DefaultOptions), - ["Vulkan:"] = new(@"Vulkan:\s*\r?\n\s*Adapter: (?.*?)\r?$", DefaultOptions), - ["Force FIFO present mode:"] = new(@"Force FIFO present mode: (?.*?)\r?$", DefaultOptions), - ["Asynchronous Texture Streaming"] = new(@"Asynchronous Texture Streaming( 2)?: (?.*?)\r?$", DefaultOptions), - ["Asynchronous Queue Scheduler:"] = new(@"Asynchronous Queue Scheduler: (?.*?)\r?$", DefaultOptions), - }, - EndTrigger = new[] {"Audio:"}, - }, - new() // Audio, Input/Output, System, Net, Miscellaneous - { - Extractors = new() - { - ["Renderer:"] = new("Renderer: (?.*?)\r?$", DefaultOptions), - ["Downmix to Stereo:"] = new("Downmix to Stereo: (?.*?)\r?$", DefaultOptions), - ["Master Volume:"] = new("Master Volume: (?.*?)\r?$", DefaultOptions), - ["Enable Buffering:"] = new("Enable Buffering: (?.*?)\r?$", DefaultOptions), - ["Desired Audio Buffer Duration:"] = new("Desired Audio Buffer Duration: (?.*?)\r?$", DefaultOptions), - ["Enable Time Stretching:"] = new("Enable Time Stretching: (?.*?)\r?$", DefaultOptions), - - ["Pad:"] = new("Pad: (?.*?)\r?$", DefaultOptions), - - ["Automatically start games after boot:"] = new("Automatically start games after boot: (?.*?)\r?$", DefaultOptions), - ["Always start after boot:"] = new("Always start after boot: (?.*?)\r?$", DefaultOptions), - ["Use native user interface:"] = new("Use native user interface: (?.*?)\r?$", DefaultOptions), - ["Silence All Logs:"] = new("Silence All Logs: (?.*?)\r?$", DefaultOptions), - }, - EndTrigger = new[] {"Log:"}, - }, - new() - { - Extractors = new() - { - ["Log:"] = new(@"Log:\s*\r?\n?\s*(\{(?.*?)\}|(?(\s+\w+\:\s*\w+\r?\n)+))\r?$", DefaultOptions), - }, - EndTrigger = new[] {"·"}, - OnSectionEnd = MarkAsComplete, - }, - new() - { - Extractors = new() - { - ["LDR: Game:"] = new(@"Game: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["LDR: Disc"] = new(@"Disc( path)?: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["LDR: Path:"] = new(@"Path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["LDR: Boot path:"] = new(@"Boot path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["SYS: Game:"] = new(@"Game: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["SYS: Path:"] = new(@"Path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["SYS: Boot path:"] = new(@"Boot path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), - ["Elf path:"] = new(@"Elf path: (?/host_root/)?(?(?/dev_hdd0/game/(?[^/\r\n]+)/USRDIR/EBOOT\.BIN|.*?))\r?$", DefaultOptions), - ["Invalid or unsupported file format:"] = new(@"Invalid or unsupported file format: (?.*?)\r?$", DefaultOptions), - ["SELF:"] = new(@"(?Failed to decrypt)? SELF: (?Failed to (decrypt|load SELF))?.*\r?$", DefaultOptions), - ["SYS: Version:"] = new(@"Version: (APP_VER=)?(?\S+) (/ |VERSION=)(?\S+).*?\r?$", DefaultOptions), - ["sceNp: npDrmIsAvailable(): Failed to verify"] = new(@"Failed to verify (?(sce|npd)) file.*\r?$", DefaultOptions), - ["{rsx::thread} RSX: 4"] = new(@"RSX:(\d|\.|\s|\w|-)* (?(\d+\.)*\d+)\r?\n[^\n]*?" + - @"RSX: [^\n]+\r?\n[^\n]*?" + - @"RSX: (?.*?)\r?\n[^\n]*?" + - @"RSX: Supported texel buffer size", DefaultOptions), - ["GL RENDERER:"] = new(@"GL RENDERER: (?.*?)\r?$", DefaultOptions), - ["GL VERSION:"] = new(@"GL VERSION: (?(\d|\.)+)(\d|\.|\s|\w|-)*?( (?(\d+\.)*\d+))?\r?$", DefaultOptions), - ["GLSL VERSION:"] = new(@"GLSL VERSION: (?(\d|\.)+).*?\r?$", DefaultOptions), - ["texel buffer size reported:"] = new(@"RSX: Supported texel buffer size reported: (?\d*?) bytes", DefaultOptions), - ["Physical device in"] = new(@"Physical device ini?tialized\. GPU=(?.+), driver=(?-?\d+)\r?$", DefaultOptions), - ["Found vulkan-compatible GPU:"] = new(@"Found vulkan-compatible GPU: (?.+)\r?$", DefaultOptions), - ["Renderer initialized on device"] = new(@"Renderer initialized on device '(?.+)'\r?$", DefaultOptions), - ["RSX: Failed to compile shader"] = new(@"RSX: Failed to compile shader: ERROR: (?.+?)\r?$", DefaultOptions), - ["RSX: Compilation failed"] = new(@"RSX: Compilation failed: ERROR: (?.+?)\r?$", DefaultOptions), - ["RSX: Unsupported device"] = new(@"RSX: Unsupported device: (?.+)\..+?\r?$", DefaultOptions), - ["RSX: Your GPU does not support"] = new(@"RSX: Your GPU does not support (?.+)\..+?\r?$", DefaultOptions), - ["RSX: GPU/driver lacks support"] = new(@"RSX: GPU/driver lacks support for (?.+)\..+?\r?$", DefaultOptions), - ["RSX: Swapchain:"] = new(@"RSX: Swapchain: present mode (?\d+?) in use.+?\r?$", DefaultOptions), - ["F "] = new(@"F \d+:\d+:\d+\.\d+ (({(?[^}]+)} )?(\w+:\s*(Thread terminated due to fatal error: )?|(\w+:\s*)?(class [^\r\n]+ thrown: ))\r?\n?)(?.*?)(\r?\n)(\r?\n|·|$)", DefaultSingleLineOptions), - ["Failed to load RAP file:"] = new(@"Failed to load RAP file: (?.*?\.rap).*\r?$", DefaultOptions), - ["Rap file not found:"] = new(@"Rap file not found: “?(?.*?\.rap)”?\r?$", DefaultOptions), - ["Pad handler expected but none initialized"] = new(@"(?Pad handler expected but none initialized).*?\r?$", DefaultOptions), - ["Failed to bind device"] = new(@"Failed to bind device (?.+) to handler (?.+).*\r?$", DefaultOptions), - ["Input:"] = new(@"Input: (?.*?) device .+ connected\r?$", DefaultOptions), - ["XAudio2Thread"] = new(@"XAudio2Thread\s*: (?.+failed\s*\((?0x.+)\).*)\r?$", DefaultOptions), - ["cellAudio Thread"] = new(@"XAudio2Backend\s*: (?.+failed\s*\((?0x.+)\).*)\r?$", DefaultOptions), - ["using a Null renderer instead"] = new(@"Audio renderer (?.+) could not be initialized\r?$", DefaultOptions), - ["PPU executable hash:"] = new(@"PPU executable hash: PPU-(?\w+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), - ["OVL executable hash:"] = new(@"OVL executable hash: OVL-(?\w+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), - ["SPU executable hash:"] = new(@"SPU executable hash: SPU-(?\w+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), - ["PRX library hash:"] = new(@"PRX library hash: PRX-(?\w+-\d+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), - ["OVL hash of"] = new(@"OVL hash of (\w|[\.\[\]])+: OVL-(?\w+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), - ["PRX hash of"] = new(@"PRX hash of (\w|[\.\[\]])+: PRX-(?\w+-\d+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), - [": Applied patch"] = new(@"Applied patch \(hash='(?:\w{3}-\w+(-\d+)?)', description='(?.+?)', author='(?:.+?)', patch_version='(?:.+?)', file_version='(?:.+?)'\) \(<- (?:[1-9]\d*)\).*\r?$", DefaultOptions), - ["Loaded SPU image:"] = new(@"Loaded SPU image: SPU-(?\w+ \(<-\s*\d+\)).*?\r?$", DefaultOptions), - ["'sys_fs_stat' failed"] = new(@"'sys_fs_stat' failed (?!with 0x8001002c).+“(/dev_bdvd/(?.+)|/dev_hdd0/game/NP\w+/(?.+))”.*?\r?$", DefaultOptions), - ["'sys_fs_open' failed"] = new(@"'sys_fs_open' failed (?!with 0x8001002c).+“(/dev_bdvd/(?.+)|/dev_hdd0/game/NP\w+/(?.+))”.*?\r?$", DefaultOptions), - ["'sys_fs_opendir' failed"] = new(@"'sys_fs_opendir' failed .+“/dev_bdvd/(?.+)”.*?\r?$", DefaultOptions), - ["EDAT: "] = new(@"EDAT: Block at offset (?0x[0-9a-f]+) has invalid hash!.*?\r?$", DefaultOptions), - ["PS3 firmware is not installed"] = new(@"(?PS3 firmware is not installed.+)\r?$", DefaultOptions), - ["do you have the PS3 firmware installed"] = new(@"(?do you have the PS3 firmware installed.*)\r?$", DefaultOptions), - ["Unimplemented syscall"] = new(@"U \d+:\d+:\d+\.\d+ ({(?.+?)} )?.*Unimplemented syscall (?.*)\r?$", DefaultOptions), - ["Could not enqueue"] = new(@"cellAudio: Could not enqueue buffer onto audio backend(?.).*\r?$", DefaultOptions), - ["{PPU["] = new(@"{PPU\[.+\]} (?[^ :]+)( TODO)?: (?!“)(?[^ :]+?)\(.*\r?$", DefaultOptions), - ["Verification failed"] = new(@"Verification failed.+\(e=0x(?[0-9a-f]+)\[(?\d+)\]\)", DefaultOptions), - ["sys_tty_write():"] = new(@"sys_tty_write\(\)\: “(?.*?)”\r?(\n|$)", DefaultSingleLineOptions), - ["⁂"] = new(@"⁂ (?[^ :\[]+?) .*\r?$", DefaultOptions), - ["undub"] = new(@"(\b|_)(?(undub|translation patch))(\b|_)", DefaultOptions | RegexOptions.IgnoreCase), - }, - OnSectionEnd = MarkAsCompleteAndReset, - EndTrigger = new[] { "Stopping emulator...", "All threads stopped...", "LDR: Booting from"}, - } - }; - - private static readonly HashSet MultiValueItems = new() + EndTrigger = new[] {"Used configuration:"}, + }, + new() { - "pad_handler", - "fatal_error_context", - "fatal_error", - "rap_file", - "vulkan_found_device", - "vulkan_compatible_device_name", - "ppu_patch", - "ovl_patch", - "spu_patch", - "prx_patch", - "patch_desc", - "broken_filename_or_dir", - "broken_filename", - "broken_digital_filename", - "broken_directory", - "edat_block_offset", - "failed_to_verify_npdrm", - "rsx_not_supported_feature", - "verification_error_hex", - "verification_error", - "tty_line", - }; - - private static readonly string[] CountValueItems = {"enqueue_buffer_error"}; - - private static async Task PiracyCheckAsync(string line, LogParseState state) - { - if (await ContentFilter.FindTriggerAsync(FilterContext.Log, line).ConfigureAwait(false) is Piracystring match - && match.Actions.HasFlag(FilterAction.RemoveContent)) + Extractors = new() { - var m = match; - if (line.Contains("not valid, removing from") - || line.Contains("Invalid disc path")) - m = new() - { - Id = match.Id, - Actions = match.Actions & ~FilterAction.IssueWarning, - Context = match.Context, - CustomMessage = match.CustomMessage, - Disabled = match.Disabled, - ExplainTerm = match.ExplainTerm, - String = match.String, - ValidatingRegex = match.ValidatingRegex, - }; - if (state.FilterTriggers.TryGetValue(m.Id, out var fh)) + ["PPU Decoder:"] = new(@"PPU Decoder: (?.*?)\r?$", DefaultOptions), + ["PPU Threads:"] = new(@"PPU Threads: (?.*?)\r?$", DefaultOptions), + ["Use LLVM CPU:"] = new("Use LLVM CPU: \\\"?(?.*?)\\\"?\r?$", DefaultOptions), + ["thread scheduler"] = new(@"[Ss]cheduler( Mode)?: (?.*?)\r?$", DefaultOptions), + ["SPU Decoder:"] = new(@"SPU Decoder: (?.*?)\r?$", DefaultOptions), + ["secondary cores:"] = new(@"secondary cores: (?.*?)\r?$", DefaultOptions), + //["priority:"] = new(@"priority: (?.*?)\r?$", DefaultOptions), + ["SPU Threads:"] = new(@"SPU Threads: (?.*?)\r?$", DefaultOptions), + ["SPU delay penalty:"] = new(@"SPU delay penalty: (?.*?)\r?$", DefaultOptions), + ["SPU loop detection:"] = new(@"SPU loop detection: (?.*?)\r?$", DefaultOptions), + ["Max SPURS Threads:"] = new(@"Max SPURS Threads: (?\d*?)\r?$", DefaultOptions), + ["SPU Block Size:"] = new(@"SPU Block Size: (?.*?)\r?$", DefaultOptions), + ["Enable TSX:"] = new(@"Enable TSX: (?.*?)\r?$", DefaultOptions), + ["Accurate xfloat:"] = new(@"Accurate xfloat: (?.*?)\r?$", DefaultOptions), + ["Accurate GETLLAR:"] = new(@"Accurate GETLLAR: (?.*?)\r?$", DefaultOptions), + ["Accurate PUTLLUC:"] = new(@"Accurate PUTLLUC: (?.*?)\r?$", DefaultOptions), + ["Accurate RSX reservation access:"] = new(@"Accurate RSX reservation access: (?.*?)\r?$", DefaultOptions), + ["Approximate xfloat:"] = new(@"Approximate xfloat: (?.*?)\r?$", DefaultOptions), + ["Debug Console Mode:"] = new(@"Debug Console Mode: (?.*?)\r?$", DefaultOptions), + ["Lib Loader:"] = new(@"[Ll]oader: (?.*?)\r?$", DefaultOptions), + ["Hook static functions:"] = new(@"Hook static functions: (?.*?)\r?$", DefaultOptions), + ["Load libraries:"] = new(@"libraries:\r?\n(?(.*?(- .*?|\[\])\r?\n)+)", DefaultOptions), + ["Libraries Control:"] = new(@"Libraries Control:\r?\n(?(.*?(- .*?|\[\])\r?\n)+)", DefaultOptions), + ["HLE lwmutex:"] = new(@"HLE lwmutex: (?.*?)\r?$", DefaultOptions), + ["Clocks scale:"] = new(@"Clocks scale: (?.*?)\r?$", DefaultOptions), + ["Sleep Timers Accuracy:"] = new(@"Sleep Timers Accuracy: (?.*?)\r?$", DefaultOptions), + }, + EndTrigger = new[] {"VFS:"}, + }, + new() + { + Extractors = new() + { + ["Enable /host_root/:"] = new(@"Enable /host_root/: (?.*?)\r?$", DefaultOptions), + }, + EndTrigger = new[] {"Video:"}, + }, + new() + { + Extractors = new() + { + ["Renderer:"] = new("Renderer: (?.*?)\r?$", DefaultOptions), + ["Resolution:"] = new("Resolution: (?.*?)\r?$", DefaultOptions), + ["Aspect ratio:"] = new("Aspect ratio: (?.*?)\r?$", DefaultOptions), + ["Frame limit:"] = new("Frame limit: (?.*?)\r?$", DefaultOptions), + ["MSAA:"] = new("MSAA: (?.*?)\r?$", DefaultOptions), + ["Write Color Buffers:"] = new("Write Color Buffers: (?.*?)\r?$", DefaultOptions), + ["Write Depth Buffer:"] = new("Write Depth Buffer: (?.*?)\r?$", DefaultOptions), + ["Read Color Buffers:"] = new("Read Color Buffers: (?.*?)\r?$", DefaultOptions), + ["Read Depth Buffer:"] = new("Read Depth Buffer: (?.*?)\r?$", DefaultOptions), + ["VSync:"] = new("VSync: (?.*?)\r?$", DefaultOptions), + ["GPU texture scaling:"] = new("Use GPU texture scaling: (?.*?)\r?$", DefaultOptions), + ["Stretch To Display Area:"] = new("Stretch To Display Area: (?.*?)\r?$", DefaultOptions), + ["Strict Rendering Mode:"] = new("Strict Rendering Mode: (?.*?)\r?$", DefaultOptions), + ["Occlusion Queries:"] = new("Occlusion Queries: (?.*?)\r?$", DefaultOptions), + ["Vertex Cache:"] = new("Disable Vertex Cache: (?.*?)\r?$", DefaultOptions), + ["Frame Skip:"] = new("Enable Frame Skip: (?.*?)\r?$", DefaultOptions), + ["Blit:"] = new("Blit: (?.*?)\r?$", DefaultOptions), + ["Disable Asynchronous Shader Compiler:"] = new("Disable Asynchronous Shader Compiler: (?.*?)\r?$", DefaultOptions), + ["Shader Mode:"] = new("Shader Mode: (?.*?)\r?$", DefaultOptions), + ["Disable native float16 support:"] = new("Disable native float16 support: (?.*?)\r?$", DefaultOptions), + ["Multithreaded RSX:"] = new("Multithreaded RSX: (?.*?)\r?$", DefaultOptions), + ["Relaxed ZCULL Sync:"] = new("Relaxed ZCULL Sync: (?.*?)\r?$", DefaultOptions), + ["Resolution Scale:"] = new("Resolution Scale: (?.*?)\r?$", DefaultOptions), + ["Anisotropic Filter"] = new("Anisotropic Filter Override: (?.*?)\r?$", DefaultOptions), + ["Scalable Dimension:"] = new("Minimum Scalable Dimension: (?.*?)\r?$", DefaultOptions), + ["Driver Recovery Timeout:"] = new("Driver Recovery Timeout: (?.*?)\r?$", DefaultOptions), + ["Driver Wake-Up Delay:"] = new("Driver Wake-Up Delay: (?.*?)\r?$", DefaultOptions), + ["Vblank Rate:"] = new("Vblank Rate: (?.*?)\r?$", DefaultOptions), + ["12:"] = new(@"(D3D12|DirectX 12):\s*\r?\n\s*Adapter: (?.*?)\r?$", DefaultOptions), + ["Vulkan:"] = new(@"Vulkan:\s*\r?\n\s*Adapter: (?.*?)\r?$", DefaultOptions), + ["Force FIFO present mode:"] = new(@"Force FIFO present mode: (?.*?)\r?$", DefaultOptions), + ["Asynchronous Texture Streaming"] = new(@"Asynchronous Texture Streaming( 2)?: (?.*?)\r?$", DefaultOptions), + ["Asynchronous Queue Scheduler:"] = new(@"Asynchronous Queue Scheduler: (?.*?)\r?$", DefaultOptions), + }, + EndTrigger = new[] {"Audio:"}, + }, + new() // Audio, Input/Output, System, Net, Miscellaneous + { + Extractors = new() + { + ["Renderer:"] = new("Renderer: (?.*?)\r?$", DefaultOptions), + ["Downmix to Stereo:"] = new("Downmix to Stereo: (?.*?)\r?$", DefaultOptions), + ["Master Volume:"] = new("Master Volume: (?.*?)\r?$", DefaultOptions), + ["Enable Buffering:"] = new("Enable Buffering: (?.*?)\r?$", DefaultOptions), + ["Desired Audio Buffer Duration:"] = new("Desired Audio Buffer Duration: (?.*?)\r?$", DefaultOptions), + ["Enable Time Stretching:"] = new("Enable Time Stretching: (?.*?)\r?$", DefaultOptions), + + ["Pad:"] = new("Pad: (?.*?)\r?$", DefaultOptions), + + ["Automatically start games after boot:"] = new("Automatically start games after boot: (?.*?)\r?$", DefaultOptions), + ["Always start after boot:"] = new("Always start after boot: (?.*?)\r?$", DefaultOptions), + ["Use native user interface:"] = new("Use native user interface: (?.*?)\r?$", DefaultOptions), + ["Silence All Logs:"] = new("Silence All Logs: (?.*?)\r?$", DefaultOptions), + }, + EndTrigger = new[] {"Log:"}, + }, + new() + { + Extractors = new() + { + ["Log:"] = new(@"Log:\s*\r?\n?\s*(\{(?.*?)\}|(?(\s+\w+\:\s*\w+\r?\n)+))\r?$", DefaultOptions), + }, + EndTrigger = new[] {"·"}, + OnSectionEnd = MarkAsComplete, + }, + new() + { + Extractors = new() + { + ["LDR: Game:"] = new(@"Game: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["LDR: Disc"] = new(@"Disc( path)?: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["LDR: Path:"] = new(@"Path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["LDR: Boot path:"] = new(@"Boot path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["SYS: Game:"] = new(@"Game: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["SYS: Path:"] = new(@"Path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["SYS: Boot path:"] = new(@"Boot path: (?.*(?/dev_hdd0/game/(?[^/\r\n]+)).*|.*)\r?$", DefaultOptions), + ["Elf path:"] = new(@"Elf path: (?/host_root/)?(?(?/dev_hdd0/game/(?[^/\r\n]+)/USRDIR/EBOOT\.BIN|.*?))\r?$", DefaultOptions), + ["Invalid or unsupported file format:"] = new(@"Invalid or unsupported file format: (?.*?)\r?$", DefaultOptions), + ["SELF:"] = new(@"(?Failed to decrypt)? SELF: (?Failed to (decrypt|load SELF))?.*\r?$", DefaultOptions), + ["SYS: Version:"] = new(@"Version: (APP_VER=)?(?\S+) (/ |VERSION=)(?\S+).*?\r?$", DefaultOptions), + ["sceNp: npDrmIsAvailable(): Failed to verify"] = new(@"Failed to verify (?(sce|npd)) file.*\r?$", DefaultOptions), + ["{rsx::thread} RSX: 4"] = new(@"RSX:(\d|\.|\s|\w|-)* (?(\d+\.)*\d+)\r?\n[^\n]*?" + + @"RSX: [^\n]+\r?\n[^\n]*?" + + @"RSX: (?.*?)\r?\n[^\n]*?" + + @"RSX: Supported texel buffer size", DefaultOptions), + ["GL RENDERER:"] = new(@"GL RENDERER: (?.*?)\r?$", DefaultOptions), + ["GL VERSION:"] = new(@"GL VERSION: (?(\d|\.)+)(\d|\.|\s|\w|-)*?( (?(\d+\.)*\d+))?\r?$", DefaultOptions), + ["GLSL VERSION:"] = new(@"GLSL VERSION: (?(\d|\.)+).*?\r?$", DefaultOptions), + ["texel buffer size reported:"] = new(@"RSX: Supported texel buffer size reported: (?\d*?) bytes", DefaultOptions), + ["Physical device in"] = new(@"Physical device ini?tialized\. GPU=(?.+), driver=(?-?\d+)\r?$", DefaultOptions), + ["Found vulkan-compatible GPU:"] = new(@"Found vulkan-compatible GPU: (?.+)\r?$", DefaultOptions), + ["Renderer initialized on device"] = new(@"Renderer initialized on device '(?.+)'\r?$", DefaultOptions), + ["RSX: Failed to compile shader"] = new(@"RSX: Failed to compile shader: ERROR: (?.+?)\r?$", DefaultOptions), + ["RSX: Compilation failed"] = new(@"RSX: Compilation failed: ERROR: (?.+?)\r?$", DefaultOptions), + ["RSX: Unsupported device"] = new(@"RSX: Unsupported device: (?.+)\..+?\r?$", DefaultOptions), + ["RSX: Your GPU does not support"] = new(@"RSX: Your GPU does not support (?.+)\..+?\r?$", DefaultOptions), + ["RSX: GPU/driver lacks support"] = new(@"RSX: GPU/driver lacks support for (?.+)\..+?\r?$", DefaultOptions), + ["RSX: Swapchain:"] = new(@"RSX: Swapchain: present mode (?\d+?) in use.+?\r?$", DefaultOptions), + ["F "] = new(@"F \d+:\d+:\d+\.\d+ (({(?[^}]+)} )?(\w+:\s*(Thread terminated due to fatal error: )?|(\w+:\s*)?(class [^\r\n]+ thrown: ))\r?\n?)(?.*?)(\r?\n)(\r?\n|·|$)", DefaultSingleLineOptions), + ["Failed to load RAP file:"] = new(@"Failed to load RAP file: (?.*?\.rap).*\r?$", DefaultOptions), + ["Rap file not found:"] = new(@"Rap file not found: “?(?.*?\.rap)”?\r?$", DefaultOptions), + ["Pad handler expected but none initialized"] = new(@"(?Pad handler expected but none initialized).*?\r?$", DefaultOptions), + ["Failed to bind device"] = new(@"Failed to bind device (?.+) to handler (?.+).*\r?$", DefaultOptions), + ["Input:"] = new(@"Input: (?.*?) device .+ connected\r?$", DefaultOptions), + ["XAudio2Thread"] = new(@"XAudio2Thread\s*: (?.+failed\s*\((?0x.+)\).*)\r?$", DefaultOptions), + ["cellAudio Thread"] = new(@"XAudio2Backend\s*: (?.+failed\s*\((?0x.+)\).*)\r?$", DefaultOptions), + ["using a Null renderer instead"] = new(@"Audio renderer (?.+) could not be initialized\r?$", DefaultOptions), + ["PPU executable hash:"] = new(@"PPU executable hash: PPU-(?\w+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), + ["OVL executable hash:"] = new(@"OVL executable hash: OVL-(?\w+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), + ["SPU executable hash:"] = new(@"SPU executable hash: SPU-(?\w+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), + ["PRX library hash:"] = new(@"PRX library hash: PRX-(?\w+-\d+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), + ["OVL hash of"] = new(@"OVL hash of (\w|[\.\[\]])+: OVL-(?\w+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), + ["PRX hash of"] = new(@"PRX hash of (\w|[\.\[\]])+: PRX-(?\w+-\d+( \(<-\s*\d+\))?).*?\r?$", DefaultOptions), + [": Applied patch"] = new(@"Applied patch \(hash='(?:\w{3}-\w+(-\d+)?)', description='(?.+?)', author='(?:.+?)', patch_version='(?:.+?)', file_version='(?:.+?)'\) \(<- (?:[1-9]\d*)\).*\r?$", DefaultOptions), + ["Loaded SPU image:"] = new(@"Loaded SPU image: SPU-(?\w+ \(<-\s*\d+\)).*?\r?$", DefaultOptions), + ["'sys_fs_stat' failed"] = new(@"'sys_fs_stat' failed (?!with 0x8001002c).+“(/dev_bdvd/(?.+)|/dev_hdd0/game/NP\w+/(?.+))”.*?\r?$", DefaultOptions), + ["'sys_fs_open' failed"] = new(@"'sys_fs_open' failed (?!with 0x8001002c).+“(/dev_bdvd/(?.+)|/dev_hdd0/game/NP\w+/(?.+))”.*?\r?$", DefaultOptions), + ["'sys_fs_opendir' failed"] = new(@"'sys_fs_opendir' failed .+“/dev_bdvd/(?.+)”.*?\r?$", DefaultOptions), + ["EDAT: "] = new(@"EDAT: Block at offset (?0x[0-9a-f]+) has invalid hash!.*?\r?$", DefaultOptions), + ["PS3 firmware is not installed"] = new(@"(?PS3 firmware is not installed.+)\r?$", DefaultOptions), + ["do you have the PS3 firmware installed"] = new(@"(?do you have the PS3 firmware installed.*)\r?$", DefaultOptions), + ["Unimplemented syscall"] = new(@"U \d+:\d+:\d+\.\d+ ({(?.+?)} )?.*Unimplemented syscall (?.*)\r?$", DefaultOptions), + ["Could not enqueue"] = new(@"cellAudio: Could not enqueue buffer onto audio backend(?.).*\r?$", DefaultOptions), + ["{PPU["] = new(@"{PPU\[.+\]} (?[^ :]+)( TODO)?: (?!“)(?[^ :]+?)\(.*\r?$", DefaultOptions), + ["Verification failed"] = new(@"Verification failed.+\(e=0x(?[0-9a-f]+)\[(?\d+)\]\)", DefaultOptions), + ["sys_tty_write():"] = new(@"sys_tty_write\(\)\: “(?.*?)”\r?(\n|$)", DefaultSingleLineOptions), + ["⁂"] = new(@"⁂ (?[^ :\[]+?) .*\r?$", DefaultOptions), + ["undub"] = new(@"(\b|_)(?(undub|translation patch))(\b|_)", DefaultOptions | RegexOptions.IgnoreCase), + }, + OnSectionEnd = MarkAsCompleteAndReset, + EndTrigger = new[] { "Stopping emulator...", "All threads stopped...", "LDR: Booting from"}, + } + }; + + private static readonly HashSet MultiValueItems = new() + { + "pad_handler", + "fatal_error_context", + "fatal_error", + "rap_file", + "vulkan_found_device", + "vulkan_compatible_device_name", + "ppu_patch", + "ovl_patch", + "spu_patch", + "prx_patch", + "patch_desc", + "broken_filename_or_dir", + "broken_filename", + "broken_digital_filename", + "broken_directory", + "edat_block_offset", + "failed_to_verify_npdrm", + "rsx_not_supported_feature", + "verification_error_hex", + "verification_error", + "tty_line", + }; + + private static readonly string[] CountValueItems = {"enqueue_buffer_error"}; + + private static async Task PiracyCheckAsync(string line, LogParseState state) + { + if (await ContentFilter.FindTriggerAsync(FilterContext.Log, line).ConfigureAwait(false) is Piracystring match + && match.Actions.HasFlag(FilterAction.RemoveContent)) + { + var m = match; + if (line.Contains("not valid, removing from") + || line.Contains("Invalid disc path")) + m = new() { - var updatedActions = fh.filter.Actions | m.Actions; - if (fh.context.Length > line.Length) - { - m.Actions = updatedActions; - state.FilterTriggers[m.Id] = (m, line.ToUtf8()); - } - else - fh.filter.Actions = updatedActions; - if (updatedActions.HasFlag(FilterAction.IssueWarning)) - state.Error = LogParseState.ErrorCode.PiracyDetected; + Id = match.Id, + Actions = match.Actions & ~FilterAction.IssueWarning, + Context = match.Context, + CustomMessage = match.CustomMessage, + Disabled = match.Disabled, + ExplainTerm = match.ExplainTerm, + String = match.String, + ValidatingRegex = match.ValidatingRegex, + }; + if (state.FilterTriggers.TryGetValue(m.Id, out var fh)) + { + var updatedActions = fh.filter.Actions | m.Actions; + if (fh.context.Length > line.Length) + { + m.Actions = updatedActions; + state.FilterTriggers[m.Id] = (m, line.ToUtf8()); } else - { - var utf8Line = line.ToUtf8(); - state.FilterTriggers[m.Id] = (m, utf8Line); - if (m.Actions.HasFlag(FilterAction.IssueWarning)) - state.Error = LogParseState.ErrorCode.PiracyDetected; - } + fh.filter.Actions = updatedActions; + if (updatedActions.HasFlag(FilterAction.IssueWarning)) + state.Error = LogParseState.ErrorCode.PiracyDetected; } - } - - private static void ClearResults(LogParseState state) - { - void Copy(params string[] keys) + else { - foreach (var key in keys) - { - if (state.CompletedCollection?[key] is string value) - state.WipCollection[key] = value; - if (state.CompleteMultiValueCollection?[key] is UniqueList collection) - state.WipMultiValueCollection[key] = collection; - } + var utf8Line = line.ToUtf8(); + state.FilterTriggers[m.Id] = (m, utf8Line); + if (m.Actions.HasFlag(FilterAction.IssueWarning)) + state.Error = LogParseState.ErrorCode.PiracyDetected; } - state.WipCollection = new(); - state.WipMultiValueCollection = new(); - Copy( - "build_and_specs", "fw_version_installed", - "first_unicode_dot", - "vulkan_gpu", "d3d_gpu", - "driver_version", "driver_manuf", - "driver_manuf_new", "driver_version_new", - "vulkan_found_device", "vulkan_compatible_device_name", - "vulkan_gpu", "vulkan_driver_version_raw", - "compat_database_path" - ); - Config.Log.Trace("===== cleared"); - } - - private static void MarkAsComplete(LogParseState state) - { - state.CompletedCollection = state.WipCollection; - state.CompleteMultiValueCollection = state.WipMultiValueCollection; - Config.Log.Trace("----- complete section"); - } - - private static void MarkAsCompleteAndReset(LogParseState state) - { - MarkAsComplete(state); - ClearResults(state); - state.Id = -1; } } -} + + private static void ClearResults(LogParseState state) + { + void Copy(params string[] keys) + { + foreach (var key in keys) + { + if (state.CompletedCollection?[key] is string value) + state.WipCollection[key] = value; + if (state.CompleteMultiValueCollection?[key] is UniqueList collection) + state.WipMultiValueCollection[key] = collection; + } + } + state.WipCollection = new(); + state.WipMultiValueCollection = new(); + Copy( + "build_and_specs", "fw_version_installed", + "first_unicode_dot", + "vulkan_gpu", "d3d_gpu", + "driver_version", "driver_manuf", + "driver_manuf_new", "driver_version_new", + "vulkan_found_device", "vulkan_compatible_device_name", + "vulkan_gpu", "vulkan_driver_version_raw", + "compat_database_path" + ); + Config.Log.Trace("===== cleared"); + } + + private static void MarkAsComplete(LogParseState state) + { + state.CompletedCollection = state.WipCollection; + state.CompleteMultiValueCollection = state.WipMultiValueCollection; + Config.Log.Trace("----- complete section"); + } + + private static void MarkAsCompleteAndReset(LogParseState state) + { + MarkAsComplete(state); + ClearResults(state); + state.Id = -1; + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/LogParser.PipeReader.cs b/CompatBot/EventHandlers/LogParsing/LogParser.PipeReader.cs index 518c373e..93b9f9b5 100644 --- a/CompatBot/EventHandlers/LogParsing/LogParser.PipeReader.cs +++ b/CompatBot/EventHandlers/LogParsing/LogParser.PipeReader.cs @@ -8,126 +8,125 @@ using System.Threading.Tasks; using CompatBot.EventHandlers.LogParsing.POCOs; using CompatBot.Utils; -namespace CompatBot.EventHandlers.LogParsing +namespace CompatBot.EventHandlers.LogParsing; + +internal static partial class LogParser { - internal static partial class LogParser + private static readonly byte[] Bom = {0xEF, 0xBB, 0xBF}; + + private static readonly PoorMansTaskScheduler TaskScheduler = new(); + + public static async Task ReadPipeAsync(PipeReader reader, CancellationToken cancellationToken) { - private static readonly byte[] Bom = {0xEF, 0xBB, 0xBF}; - - private static readonly PoorMansTaskScheduler TaskScheduler = new(); - - public static async Task ReadPipeAsync(PipeReader reader, CancellationToken cancellationToken) + var currentSectionLines = new LinkedList>(); + var state = new LogParseState(); + var skippedBom = false; + long totalReadBytes = 0; + ReadResult result; + do { - var currentSectionLines = new LinkedList>(); - var state = new LogParseState(); - var skippedBom = false; - long totalReadBytes = 0; - ReadResult result; - do + try { - try + result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + var buffer = result.Buffer; + if (!skippedBom) { - result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); - var buffer = result.Buffer; - if (!skippedBom) - { - if (buffer.Length < 3) - continue; + if (buffer.Length < 3) + continue; - var potentialBom = buffer.Slice(0, 3); - if (potentialBom.ToArray().SequenceEqual(Bom)) - { - reader.AdvanceTo(potentialBom.End); - totalReadBytes += potentialBom.Length; - skippedBom = true; - continue; - } + var potentialBom = buffer.Slice(0, 3); + if (potentialBom.ToArray().SequenceEqual(Bom)) + { + reader.AdvanceTo(potentialBom.End); + totalReadBytes += potentialBom.Length; skippedBom = true; + continue; } - SequencePosition? lineEnd; - do - { - if (currentSectionLines.Last is {} lastLine) - buffer = buffer.Slice(buffer.GetPosition(1, lastLine.Value.End)); - lineEnd = buffer.PositionOf((byte)'\n'); - if (lineEnd is null) - continue; - - await OnNewLineAsync(buffer.Slice(0, lineEnd.Value), result.Buffer, currentSectionLines, state).ConfigureAwait(false); - if (state.Error != LogParseState.ErrorCode.None) - { - await reader.CompleteAsync(); - return state; - } - - buffer = buffer.Slice(buffer.GetPosition(1, lineEnd.Value)); - } while (lineEnd != null); - - if (result.IsCanceled || cancellationToken.IsCancellationRequested) - { - if (state.Error == LogParseState.ErrorCode.None) - state.Error = LogParseState.ErrorCode.SizeLimit; - } - else if (result.IsCompleted) - { - if (!buffer.End.Equals(currentSectionLines.Last?.Value.End)) - await OnNewLineAsync(buffer.Slice(0), result.Buffer, currentSectionLines, state).ConfigureAwait(false); - await FlushAllLinesAsync(result.Buffer, currentSectionLines, state).ConfigureAwait(false); - } - var sectionStart = currentSectionLines.First is {} firstLine ? firstLine.Value : buffer; - totalReadBytes += result.Buffer.Slice(0, sectionStart.Start).Length; - reader.AdvanceTo(sectionStart.Start); + skippedBom = true; } - catch (Exception e) + SequencePosition? lineEnd; + do + { + if (currentSectionLines.Last is {} lastLine) + buffer = buffer.Slice(buffer.GetPosition(1, lastLine.Value.End)); + lineEnd = buffer.PositionOf((byte)'\n'); + if (lineEnd is null) + continue; + + await OnNewLineAsync(buffer.Slice(0, lineEnd.Value), result.Buffer, currentSectionLines, state).ConfigureAwait(false); + if (state.Error != LogParseState.ErrorCode.None) + { + await reader.CompleteAsync(); + return state; + } + + buffer = buffer.Slice(buffer.GetPosition(1, lineEnd.Value)); + } while (lineEnd != null); + + if (result.IsCanceled || cancellationToken.IsCancellationRequested) { - Config.Log.Warn(e, "Aborted log parsing due to exception"); if (state.Error == LogParseState.ErrorCode.None) - state.Error = LogParseState.ErrorCode.UnknownError; - break; + state.Error = LogParseState.ErrorCode.SizeLimit; } - } while (!(result.IsCompleted || result.IsCanceled || cancellationToken.IsCancellationRequested)); - await TaskScheduler.WaitForClearTagAsync(state).ConfigureAwait(false); - state.ReadBytes = totalReadBytes; - await reader.CompleteAsync(); - return state; - } - - private static async Task OnNewLineAsync(ReadOnlySequence line, ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) - { - var currentProcessor = SectionParsers[state.Id]; - var strLine = line.AsString(); - if (currentProcessor.EndTrigger.Any(et => strLine.Contains(et))) - { - await FlushAllLinesAsync(buffer, sectionLines, state).ConfigureAwait(false); - await TaskScheduler.WaitForClearTagAsync(state).ConfigureAwait(false); - SectionParsers[state.Id].OnSectionEnd?.Invoke(state); - state.Id++; + else if (result.IsCompleted) + { + if (!buffer.End.Equals(currentSectionLines.Last?.Value.End)) + await OnNewLineAsync(buffer.Slice(0), result.Buffer, currentSectionLines, state).ConfigureAwait(false); + await FlushAllLinesAsync(result.Buffer, currentSectionLines, state).ConfigureAwait(false); + } + var sectionStart = currentSectionLines.First is {} firstLine ? firstLine.Value : buffer; + totalReadBytes += result.Buffer.Slice(0, sectionStart.Start).Length; + reader.AdvanceTo(sectionStart.Start); } - if (sectionLines.Count == 50) - await ProcessFirstLineInBufferAsync(buffer, sectionLines, state).ConfigureAwait(false); - sectionLines.AddLast(line); - } - - private static async Task FlushAllLinesAsync(ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) - { - while (sectionLines.Count > 0 && state.Error == LogParseState.ErrorCode.None) - await ProcessFirstLineInBufferAsync(buffer, sectionLines, state).ConfigureAwait(false); - } - - private static async Task ProcessFirstLineInBufferAsync(ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) - { - var currentProcessor = SectionParsers[state.Id]; - if (sectionLines.First is null) - return; - - var firstSectionLine = sectionLines.First.Value.AsString(); - await PiracyCheckAsync(firstSectionLine, state).ConfigureAwait(false); - if (state.Error != LogParseState.ErrorCode.None) - return; - - var section = buffer.Slice(sectionLines.First.Value.Start, sectionLines.Last!.Value.End).AsString(); - await TaskScheduler.AddAsync(state, Task.Run(() => currentProcessor.OnExtract(firstSectionLine, section, state))); - sectionLines.RemoveFirst(); - } + catch (Exception e) + { + Config.Log.Warn(e, "Aborted log parsing due to exception"); + if (state.Error == LogParseState.ErrorCode.None) + state.Error = LogParseState.ErrorCode.UnknownError; + break; + } + } while (!(result.IsCompleted || result.IsCanceled || cancellationToken.IsCancellationRequested)); + await TaskScheduler.WaitForClearTagAsync(state).ConfigureAwait(false); + state.ReadBytes = totalReadBytes; + await reader.CompleteAsync(); + return state; } -} + + private static async Task OnNewLineAsync(ReadOnlySequence line, ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) + { + var currentProcessor = SectionParsers[state.Id]; + var strLine = line.AsString(); + if (currentProcessor.EndTrigger.Any(et => strLine.Contains(et))) + { + await FlushAllLinesAsync(buffer, sectionLines, state).ConfigureAwait(false); + await TaskScheduler.WaitForClearTagAsync(state).ConfigureAwait(false); + SectionParsers[state.Id].OnSectionEnd?.Invoke(state); + state.Id++; + } + if (sectionLines.Count == 50) + await ProcessFirstLineInBufferAsync(buffer, sectionLines, state).ConfigureAwait(false); + sectionLines.AddLast(line); + } + + private static async Task FlushAllLinesAsync(ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) + { + while (sectionLines.Count > 0 && state.Error == LogParseState.ErrorCode.None) + await ProcessFirstLineInBufferAsync(buffer, sectionLines, state).ConfigureAwait(false); + } + + private static async Task ProcessFirstLineInBufferAsync(ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) + { + var currentProcessor = SectionParsers[state.Id]; + if (sectionLines.First is null) + return; + + var firstSectionLine = sectionLines.First.Value.AsString(); + await PiracyCheckAsync(firstSectionLine, state).ConfigureAwait(false); + if (state.Error != LogParseState.ErrorCode.None) + return; + + var section = buffer.Slice(sectionLines.First.Value.Start, sectionLines.Last!.Value.End).AsString(); + await TaskScheduler.AddAsync(state, Task.Run(() => currentProcessor.OnExtract(firstSectionLine, section, state))); + sectionLines.RemoveFirst(); + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/LogParser.StateMachineGenerator.cs b/CompatBot/EventHandlers/LogParsing/LogParser.StateMachineGenerator.cs index df0bda7f..7ea901f8 100644 --- a/CompatBot/EventHandlers/LogParsing/LogParser.StateMachineGenerator.cs +++ b/CompatBot/EventHandlers/LogParsing/LogParser.StateMachineGenerator.cs @@ -7,110 +7,109 @@ using CompatBot.EventHandlers.LogParsing.POCOs; using CompatBot.Utils; using NReco.Text; -namespace CompatBot.EventHandlers.LogParsing +namespace CompatBot.EventHandlers.LogParsing; + +using SectionAction = KeyValuePair>; + +internal partial class LogParser { - using SectionAction = KeyValuePair>; + private static readonly ReadOnlyCollection SectionParsers; - internal partial class LogParser + static LogParser() { - private static readonly ReadOnlyCollection SectionParsers; - - static LogParser() + var parsers = new List(LogSections.Count); + foreach (var sectionDescription in LogSections) { - var parsers = new List(LogSections.Count); - foreach (var sectionDescription in LogSections) + var parser = new LogSectionParser { - var parser = new LogSectionParser - { - OnSectionEnd = sectionDescription.OnSectionEnd, - EndTrigger = sectionDescription.EndTrigger.Select(s => s.ToLatin8BitEncoding()).ToArray(), - }; - // the idea here is to construct Aho-Corasick parser that will look for any data marker and run the associated regex to extract the data into state - if (sectionDescription.Extractors.Count > 0) - { - var act = new AhoCorasickDoubleArrayTrie>(sectionDescription.Extractors.Select(extractorPair => - new SectionAction( - extractorPair.Key.ToLatin8BitEncoding(), - (buffer, state) => + OnSectionEnd = sectionDescription.OnSectionEnd, + EndTrigger = sectionDescription.EndTrigger.Select(s => s.ToLatin8BitEncoding()).ToArray(), + }; + // the idea here is to construct Aho-Corasick parser that will look for any data marker and run the associated regex to extract the data into state + if (sectionDescription.Extractors.Count > 0) + { + var act = new AhoCorasickDoubleArrayTrie>(sectionDescription.Extractors.Select(extractorPair => + new SectionAction( + extractorPair.Key.ToLatin8BitEncoding(), + (buffer, state) => + { +#if DEBUG + var timer = System.Diagnostics.Stopwatch.StartNew(); +#endif + OnExtractorHit(buffer, extractorPair.Key, extractorPair.Value, state); + +#if DEBUG + timer.Stop(); + lock (state.ExtractorHitStats) { -#if DEBUG - var timer = System.Diagnostics.Stopwatch.StartNew(); + state.ExtractorHitStats.TryGetValue(extractorPair.Key, out var stat); + state.ExtractorHitStats[extractorPair.Key] = (stat.count + 1, stat.regexTime + timer.ElapsedTicks); + } #endif - OnExtractorHit(buffer, extractorPair.Key, extractorPair.Value, state); - -#if DEBUG - timer.Stop(); - lock (state.ExtractorHitStats) - { - state.ExtractorHitStats.TryGetValue(extractorPair.Key, out var stat); - state.ExtractorHitStats[extractorPair.Key] = (stat.count + 1, stat.regexTime + timer.ElapsedTicks); - } -#endif - }) - ), true); - parser.OnExtract = (line, buffer, state) => { act.ParseText(line, h => { h.Value(buffer, state); }); }; - } - parsers.Add(parser); + }) + ), true); + parser.OnExtract = (line, buffer, state) => { act.ParseText(line, h => { h.Value(buffer, state); }); }; } - SectionParsers = parsers.AsReadOnly(); + parsers.Add(parser); } + SectionParsers = parsers.AsReadOnly(); + } - private static void OnExtractorHit(string buffer, string trigger, Regex extractor, LogParseState state) + private static void OnExtractorHit(string buffer, string trigger, Regex extractor, LogParseState state) + { + if (trigger == "{PPU[" || trigger == "⁂") { - if (trigger == "{PPU[" || trigger == "⁂") + if (state.WipCollection["serial"] is string serial + && extractor.Match(buffer) is Match match + && match.Success + && match.Groups["syscall_name"].Value is string syscallName) { - if (state.WipCollection["serial"] is string serial - && extractor.Match(buffer) is Match match - && match.Success - && match.Groups["syscall_name"].Value is string syscallName) + lock (state) { - lock (state) - { - if (!state.Syscalls.TryGetValue(serial, out var serialSyscallStats)) - state.Syscalls[serial] = serialSyscallStats = new(); - serialSyscallStats.Add(syscallName); - } + if (!state.Syscalls.TryGetValue(serial, out var serialSyscallStats)) + state.Syscalls[serial] = serialSyscallStats = new(); + serialSyscallStats.Add(syscallName); } } - else - { - var matches = extractor.Matches(buffer); - if (matches.Count == 0) - return; + } + else + { + var matches = extractor.Matches(buffer); + if (matches.Count == 0) + return; - foreach (Match match in matches) - foreach (Group group in match.Groups) + foreach (Match match in matches) + foreach (Group group in match.Groups) + { + if (string.IsNullOrEmpty(group.Name) + || group.Name == "0" + || string.IsNullOrWhiteSpace(group.Value)) + continue; + + var strValue = group.Value.ToUtf8(); + //Config.Log.Trace($"regex {group.Name} = {group.Value}"); + lock (state) { - if (string.IsNullOrEmpty(group.Name) - || group.Name == "0" - || string.IsNullOrWhiteSpace(group.Value)) + if (MultiValueItems.Contains(group.Name)) + state.WipMultiValueCollection[group.Name].Add(strValue); + else + state.WipCollection[group.Name] = strValue; + if (!CountValueItems.Contains(group.Name)) continue; - - var strValue = group.Value.ToUtf8(); - //Config.Log.Trace($"regex {group.Name} = {group.Value}"); - lock (state) - { - if (MultiValueItems.Contains(group.Name)) - state.WipMultiValueCollection[group.Name].Add(strValue); - else - state.WipCollection[group.Name] = strValue; - if (!CountValueItems.Contains(group.Name)) - continue; - state.ValueHitStats.TryGetValue(group.Name, out var hits); - state.ValueHitStats[group.Name] = ++hits; - } + state.ValueHitStats.TryGetValue(group.Name, out var hits); + state.ValueHitStats[group.Name] = ++hits; } } } - - private delegate void OnNewLineDelegate(string line, string buffer, LogParseState state); - - private class LogSectionParser - { - public OnNewLineDelegate OnExtract = null!; - public Action? OnSectionEnd; - public string[] EndTrigger = null!; - } + } + + private delegate void OnNewLineDelegate(string line, string buffer, LogParseState state); + + private class LogSectionParser + { + public OnNewLineDelegate OnExtract = null!; + public Action? OnSectionEnd; + public string[] EndTrigger = null!; } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/POCOs/LogParseState.cs b/CompatBot/EventHandlers/LogParsing/POCOs/LogParseState.cs index 5f4fba06..ad578fe2 100644 --- a/CompatBot/EventHandlers/LogParsing/POCOs/LogParseState.cs +++ b/CompatBot/EventHandlers/LogParsing/POCOs/LogParseState.cs @@ -4,34 +4,33 @@ using System.Collections.Specialized; using CompatBot.Database; using CompatBot.Utils; -namespace CompatBot.EventHandlers.LogParsing.POCOs +namespace CompatBot.EventHandlers.LogParsing.POCOs; + +public class LogParseState { - public class LogParseState - { - public NameValueCollection? CompletedCollection; - public NameUniqueObjectCollection? CompleteMultiValueCollection; - public NameValueCollection WipCollection = new(); - public NameUniqueObjectCollection WipMultiValueCollection = new(); - public readonly Dictionary ValueHitStats = new(); - public readonly Dictionary> Syscalls = new(); - public int Id = 0; - public ErrorCode Error = ErrorCode.None; - public readonly Dictionary FilterTriggers = new(); - public Piracystring? SelectedFilter; - public string? SelectedFilterContext; - public long ReadBytes; - public long TotalBytes; - public TimeSpan ParsingTime; + public NameValueCollection? CompletedCollection; + public NameUniqueObjectCollection? CompleteMultiValueCollection; + public NameValueCollection WipCollection = new(); + public NameUniqueObjectCollection WipMultiValueCollection = new(); + public readonly Dictionary ValueHitStats = new(); + public readonly Dictionary> Syscalls = new(); + public int Id = 0; + public ErrorCode Error = ErrorCode.None; + public readonly Dictionary FilterTriggers = new(); + public Piracystring? SelectedFilter; + public string? SelectedFilterContext; + public long ReadBytes; + public long TotalBytes; + public TimeSpan ParsingTime; #if DEBUG - public readonly Dictionary ExtractorHitStats = new(); + public readonly Dictionary ExtractorHitStats = new(); #endif - public enum ErrorCode - { - None = 0, - PiracyDetected = 1, - SizeLimit = 2, - UnknownError = 3, - } + public enum ErrorCode + { + None = 0, + PiracyDetected = 1, + SizeLimit = 2, + UnknownError = 3, } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/POCOs/LogSection.cs b/CompatBot/EventHandlers/LogParsing/POCOs/LogSection.cs index 84517b5b..504002cd 100644 --- a/CompatBot/EventHandlers/LogParsing/POCOs/LogSection.cs +++ b/CompatBot/EventHandlers/LogParsing/POCOs/LogSection.cs @@ -3,28 +3,27 @@ using System.Collections.Generic; using System.Text.RegularExpressions; using CompatBot.Utils; -namespace CompatBot.EventHandlers.LogParsing.POCOs +namespace CompatBot.EventHandlers.LogParsing.POCOs; + +internal class LogSection { - internal class LogSection + public string[] EndTrigger = null!; + + public Dictionary Extractors { - public string[] EndTrigger = null!; - - public Dictionary Extractors + get => extractors; + init { - get => extractors; - init + var result = new Dictionary(value.Count); + foreach (var key in value.Keys) { - var result = new Dictionary(value.Count); - foreach (var key in value.Keys) - { - var r = value[key]; - result[key] = new(r.ToLatin8BitRegexPattern(), r.Options); - } - extractors = result; + var r = value[key]; + result[key] = new(r.ToLatin8BitRegexPattern(), r.Options); } + extractors = result; } - - public Action? OnSectionEnd; - private readonly Dictionary extractors = null!; } + + public Action? OnSectionEnd; + private readonly Dictionary extractors = null!; } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/BaseSourceHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/BaseSourceHandler.cs index 2c8d8c3b..5c86b405 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/BaseSourceHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/BaseSourceHandler.cs @@ -5,14 +5,13 @@ using System.Threading.Tasks; using CompatBot.EventHandlers.LogParsing.ArchiveHandlers; using DSharpPlus.Entities; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers -{ - internal abstract class BaseSourceHandler: ISourceHandler - { - protected const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture; - protected const int SnoopBufferSize = 4096; - internal static readonly ArrayPool BufferPool = ArrayPool.Create(SnoopBufferSize, 64); +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; - public abstract Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers); - } -} +internal abstract class BaseSourceHandler: ISourceHandler +{ + protected const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture; + protected const int SnoopBufferSize = 4096; + internal static readonly ArrayPool BufferPool = ArrayPool.Create(SnoopBufferSize, 64); + + public abstract Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers); +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/DiscordAttachmentHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/DiscordAttachmentHandler.cs index 1f713d63..9e6cea74 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/DiscordAttachmentHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/DiscordAttachmentHandler.cs @@ -8,69 +8,68 @@ using CompatBot.EventHandlers.LogParsing.ArchiveHandlers; using CompatBot.Utils; using DSharpPlus.Entities; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class DiscordAttachmentHandler : BaseSourceHandler { - internal sealed class DiscordAttachmentHandler : BaseSourceHandler + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + using var client = HttpClientFactory.Create(); + foreach (var attachment in message.Attachments) { - using var client = HttpClientFactory.Create(); - foreach (var attachment in message.Attachments) + try { + await using var stream = await client.GetStreamAsync(attachment.Url).ConfigureAwait(false); + var buf = BufferPool.Rent(SnoopBufferSize); try { - await using var stream = await client.GetStreamAsync(attachment.Url).ConfigureAwait(false); - var buf = BufferPool.Rent(SnoopBufferSize); - try + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + foreach (var handler in handlers) { - var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(attachment.FileName, attachment.FileSize, buf.AsSpan(0, read)); - if (canHandle) - return (new DiscordAttachmentSource(attachment, handler, attachment.FileName, attachment.FileSize), null); - else if (!string.IsNullOrEmpty(reason)) - return (null, reason); - } - } - finally - { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(attachment.FileName, attachment.FileSize, buf.AsSpan(0, read)); + if (canHandle) + return (new DiscordAttachmentSource(attachment, handler, attachment.FileName, attachment.FileSize), null); + else if (!string.IsNullOrEmpty(reason)) + return (null, reason); } } - catch (Exception e) + finally { - Config.Log.Error(e, "Error sniffing the rar content"); + BufferPool.Return(buf); } } - return (null, null); + catch (Exception e) + { + Config.Log.Error(e, "Error sniffing the rar content"); + } + } + return (null, null); + } + + private sealed class DiscordAttachmentSource : ISource + { + private readonly DiscordAttachment attachment; + private readonly IArchiveHandler handler; + + public string SourceType => "Discord attachment"; + public string FileName { get; } + public long SourceFileSize { get; } + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + internal DiscordAttachmentSource(DiscordAttachment attachment, IArchiveHandler handler, string fileName, int fileSize) + { + this.attachment = attachment; + this.handler = handler; + FileName = fileName; + SourceFileSize = fileSize; } - private sealed class DiscordAttachmentSource : ISource + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) { - private readonly DiscordAttachment attachment; - private readonly IArchiveHandler handler; - - public string SourceType => "Discord attachment"; - public string FileName { get; } - public long SourceFileSize { get; } - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - internal DiscordAttachmentSource(DiscordAttachment attachment, IArchiveHandler handler, string fileName, int fileSize) - { - this.attachment = attachment; - this.handler = handler; - FileName = fileName; - SourceFileSize = fileSize; - } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - using var client = HttpClientFactory.Create(); - await using var stream = await client.GetStreamAsync(attachment.Url, cancellationToken).ConfigureAwait(false); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); - } + using var client = HttpClientFactory.Create(); + await using var stream = await client.GetStreamAsync(attachment.Url, cancellationToken).ConfigureAwait(false); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/DropboxHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/DropboxHandler.cs index 93215c50..0ac538d1 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/DropboxHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/DropboxHandler.cs @@ -11,99 +11,98 @@ using System.Net.Http; using System.Threading; using CompatApiClient.Utils; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class DropboxHandler : BaseSourceHandler { - internal sealed class DropboxHandler : BaseSourceHandler + //https://www.dropbox.com/s/62ls9lw5i52fuib/RPCS3.log.gz?dl=0 + private static readonly Regex ExternalLink = new(@"(?(https?://)?(www\.)?dropbox\.com/s/(?[^/\s]+)/(?[^/\?\s])(/dl=[01])?)", DefaultOptions); + + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - //https://www.dropbox.com/s/62ls9lw5i52fuib/RPCS3.log.gz?dl=0 - private static readonly Regex ExternalLink = new(@"(?(https?://)?(www\.)?dropbox\.com/s/(?[^/\s]+)/(?[^/\?\s])(/dl=[01])?)", DefaultOptions); + if (string.IsNullOrEmpty(message.Content)) + return (null, null); - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + var matches = ExternalLink.Matches(message.Content); + if (matches.Count == 0) + return (null, null); + + using var client = HttpClientFactory.Create(); + foreach (Match m in matches) { - if (string.IsNullOrEmpty(message.Content)) - return (null, null); - - var matches = ExternalLink.Matches(message.Content); - if (matches.Count == 0) - return (null, null); - - using var client = HttpClientFactory.Create(); - foreach (Match m in matches) + if (m.Groups["dropbox_link"].Value is string lnk + && !string.IsNullOrEmpty(lnk) + && Uri.TryCreate(lnk, UriKind.Absolute, out var uri)) { - if (m.Groups["dropbox_link"].Value is string lnk - && !string.IsNullOrEmpty(lnk) - && Uri.TryCreate(lnk, UriKind.Absolute, out var uri)) + try { + uri = uri.SetQueryParameter("dl", "1"); + var filename = Path.GetFileName(lnk); + var filesize = -1; + + using (var request = new HttpRequestMessage(HttpMethod.Head, uri)) + { + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Config.Cts.Token); + if (response.Content.Headers.ContentLength > 0) + filesize = (int)response.Content.Headers.ContentLength.Value; + if (response.Content.Headers.ContentDisposition?.FileNameStar is string fname && !string.IsNullOrEmpty(fname)) + filename = fname; + uri = response.RequestMessage?.RequestUri; + } + + await using var stream = await client.GetStreamAsync(uri).ConfigureAwait(false); + var buf = BufferPool.Rent(SnoopBufferSize); try { - uri = uri.SetQueryParameter("dl", "1"); - var filename = Path.GetFileName(lnk); - var filesize = -1; - - using (var request = new HttpRequestMessage(HttpMethod.Head, uri)) + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + foreach (var handler in handlers) { - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Config.Cts.Token); - if (response.Content.Headers.ContentLength > 0) - filesize = (int)response.Content.Headers.ContentLength.Value; - if (response.Content.Headers.ContentDisposition?.FileNameStar is string fname && !string.IsNullOrEmpty(fname)) - filename = fname; - uri = response.RequestMessage?.RequestUri; - } - - await using var stream = await client.GetStreamAsync(uri).ConfigureAwait(false); - var buf = BufferPool.Rent(SnoopBufferSize); - try - { - var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); - if (canHandle) - return (new DropboxSource(uri, handler, filename, filesize), null); - else if (!string.IsNullOrEmpty(reason)) - return (null, reason); - } - } - finally - { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); + if (canHandle) + return (new DropboxSource(uri, handler, filename, filesize), null); + else if (!string.IsNullOrEmpty(reason)) + return (null, reason); } } - - catch (Exception e) + finally { - Config.Log.Warn(e, $"Error sniffing {m.Groups["dropbox_link"].Value}"); + BufferPool.Return(buf); } } + + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["dropbox_link"].Value}"); + } } - return (null, null); + } + return (null, null); + } + + private sealed class DropboxSource : ISource + { + private readonly Uri? uri; + private readonly IArchiveHandler handler; + + public string SourceType => "Dropbox"; + public string FileName { get; } + public long SourceFileSize { get; } + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + internal DropboxSource(Uri? uri, IArchiveHandler handler, string fileName, int fileSize) + { + this.uri = uri; + this.handler = handler; + FileName = fileName; + SourceFileSize = fileSize; } - private sealed class DropboxSource : ISource + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) { - private readonly Uri? uri; - private readonly IArchiveHandler handler; - - public string SourceType => "Dropbox"; - public string FileName { get; } - public long SourceFileSize { get; } - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - internal DropboxSource(Uri? uri, IArchiveHandler handler, string fileName, int fileSize) - { - this.uri = uri; - this.handler = handler; - FileName = fileName; - SourceFileSize = fileSize; - } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - using var client = HttpClientFactory.Create(); - await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); - } + using var client = HttpClientFactory.Create(); + await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/FileSourceHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/FileSourceHandler.cs index 5fdb6cc6..0989b5cd 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/FileSourceHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/FileSourceHandler.cs @@ -7,49 +7,48 @@ using System.Threading.Tasks; using CompatBot.EventHandlers.LogParsing.ArchiveHandlers; using CompatBot.Utils; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal class FileSource : ISource { - internal class FileSource : ISource + private readonly string path; + private readonly IArchiveHandler handler; + + public FileSource(string path, IArchiveHandler handler) { - private readonly string path; - private readonly IArchiveHandler handler; + this.path = path; + this.handler = handler; + var fileInfo = new FileInfo(path); + SourceFileSize = fileInfo.Length; + FileName = fileInfo.Name; + } - public FileSource(string path, IArchiveHandler handler) + public string SourceType => "File"; + public string FileName { get; } + public long SourceFileSize { get; } + public long SourceFilePosition { get; } + public long LogFileSize { get; } + + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) + { + await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); + } + + public static async Task DetectArchiveHandlerAsync(string path, ICollection handlers) + { + var buf = new byte[4096]; + await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + foreach (var handler in handlers) { - this.path = path; - this.handler = handler; - var fileInfo = new FileInfo(path); - SourceFileSize = fileInfo.Length; - FileName = fileInfo.Name; - } - - public string SourceType => "File"; - public string FileName { get; } - public long SourceFileSize { get; } - public long SourceFilePosition { get; } - public long LogFileSize { get; } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); - } - - public static async Task DetectArchiveHandlerAsync(string path, ICollection handlers) - { - var buf = new byte[4096]; - await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(Path.GetFileName(path), (int)stream.Length, buf.AsSpan(0, read)); - if (canHandle) - return new FileSource(path, handler); + var (canHandle, reason) = handler.CanHandle(Path.GetFileName(path), (int)stream.Length, buf.AsSpan(0, read)); + if (canHandle) + return new FileSource(path, handler); - if (!string.IsNullOrEmpty(reason)) - throw new InvalidOperationException(reason); - } - throw new InvalidOperationException("Unknown source type"); + if (!string.IsNullOrEmpty(reason)) + throw new InvalidOperationException(reason); } + throw new InvalidOperationException("Unknown source type"); } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/GenericLinkHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/GenericLinkHandler.cs index 2f7b23c8..e1a36c3f 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/GenericLinkHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/GenericLinkHandler.cs @@ -10,100 +10,99 @@ using System.IO.Pipelines; using System.Net.Http; using System.Threading; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class GenericLinkHandler : BaseSourceHandler { - internal sealed class GenericLinkHandler : BaseSourceHandler + private static readonly Regex ExternalLink = new(@"(?(https?://)?(github\.com/RPCS3/rpcs3|cdn\.discordapp\.com/attachments)/.*/(?[^/\?\s]+\.(gz|zip|rar|7z|log)))", DefaultOptions); + + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - private static readonly Regex ExternalLink = new(@"(?(https?://)?(github\.com/RPCS3/rpcs3|cdn\.discordapp\.com/attachments)/.*/(?[^/\?\s]+\.(gz|zip|rar|7z|log)))", DefaultOptions); + if (string.IsNullOrEmpty(message.Content)) + return (null, null); - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + var matches = ExternalLink.Matches(message.Content); + if (matches.Count == 0) + return (null, null); + + using var client = HttpClientFactory.Create(); + foreach (Match m in matches) { - if (string.IsNullOrEmpty(message.Content)) - return (null, null); - - var matches = ExternalLink.Matches(message.Content); - if (matches.Count == 0) - return (null, null); - - using var client = HttpClientFactory.Create(); - foreach (Match m in matches) + if (m.Groups["link"].Value is string lnk + && !string.IsNullOrEmpty(lnk) + && Uri.TryCreate(lnk, UriKind.Absolute, out var uri) + && !"tty.log".Equals(m.Groups["filename"].Value, StringComparison.InvariantCultureIgnoreCase)) { - if (m.Groups["link"].Value is string lnk - && !string.IsNullOrEmpty(lnk) - && Uri.TryCreate(lnk, UriKind.Absolute, out var uri) - && !"tty.log".Equals(m.Groups["filename"].Value, StringComparison.InvariantCultureIgnoreCase)) + try { + var host = uri.Host; + var filename = Path.GetFileName(lnk); + var filesize = -1; + + using (var request = new HttpRequestMessage(HttpMethod.Head, uri)) + { + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Config.Cts.Token); + if (response.Content.Headers.ContentLength > 0) + filesize = (int)response.Content.Headers.ContentLength.Value; + if (response.Content.Headers.ContentDisposition?.FileNameStar is string fname && !string.IsNullOrEmpty(fname)) + filename = fname; + uri = response.RequestMessage?.RequestUri; + } + + await using var stream = await client.GetStreamAsync(uri).ConfigureAwait(false); + var buf = BufferPool.Rent(SnoopBufferSize); try { - var host = uri.Host; - var filename = Path.GetFileName(lnk); - var filesize = -1; - - using (var request = new HttpRequestMessage(HttpMethod.Head, uri)) + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + foreach (var handler in handlers) { - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Config.Cts.Token); - if (response.Content.Headers.ContentLength > 0) - filesize = (int)response.Content.Headers.ContentLength.Value; - if (response.Content.Headers.ContentDisposition?.FileNameStar is string fname && !string.IsNullOrEmpty(fname)) - filename = fname; - uri = response.RequestMessage?.RequestUri; - } - - await using var stream = await client.GetStreamAsync(uri).ConfigureAwait(false); - var buf = BufferPool.Rent(SnoopBufferSize); - try - { - var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); - if (canHandle) - return (new GenericSource(uri, handler, host, filename, filesize), null); - else if (!string.IsNullOrEmpty(reason)) - return (null, reason); - } - } - finally - { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); + if (canHandle) + return (new GenericSource(uri, handler, host, filename, filesize), null); + else if (!string.IsNullOrEmpty(reason)) + return (null, reason); } } - catch (Exception e) + finally { - Config.Log.Warn(e, $"Error sniffing {m.Groups["link"].Value}"); + BufferPool.Return(buf); } } + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["link"].Value}"); + } } - return (null, null); + } + return (null, null); + } + + private sealed class GenericSource : ISource + { + private readonly Uri? uri; + private readonly IArchiveHandler handler; + + public string SourceType => "Generic link"; + public string FileName { get; } + public string Host { get; } + public long SourceFileSize { get; } + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + internal GenericSource(Uri? uri, IArchiveHandler handler, string host, string fileName, int fileSize) + { + this.uri = uri; + this.handler = handler; + Host = host; + FileName = fileName; + SourceFileSize = fileSize; } - private sealed class GenericSource : ISource + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) { - private readonly Uri? uri; - private readonly IArchiveHandler handler; - - public string SourceType => "Generic link"; - public string FileName { get; } - public string Host { get; } - public long SourceFileSize { get; } - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - internal GenericSource(Uri? uri, IArchiveHandler handler, string host, string fileName, int fileSize) - { - this.uri = uri; - this.handler = handler; - Host = host; - FileName = fileName; - SourceFileSize = fileSize; - } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - using var client = HttpClientFactory.Create(); - await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); - } + using var client = HttpClientFactory.Create(); + await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/GoogleDriveHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/GoogleDriveHandler.cs index 610503f4..984e16f0 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/GoogleDriveHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/GoogleDriveHandler.cs @@ -14,127 +14,126 @@ using Google.Apis.Drive.v3; using Google.Apis.Services; using FileMeta = Google.Apis.Drive.v3.Data.File; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class GoogleDriveHandler: BaseSourceHandler { - internal sealed class GoogleDriveHandler: BaseSourceHandler + private static readonly Regex ExternalLink = new(@"(?(https?://)?drive\.google\.com/(open\?id=|file/d/)(?[^/>\s]+))", DefaultOptions); + private static readonly string[] Scopes = { DriveService.Scope.DriveReadonly }; + private static readonly string ApplicationName = "RPCS3 Compatibility Bot 2.0"; + + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - private static readonly Regex ExternalLink = new(@"(?(https?://)?drive\.google\.com/(open\?id=|file/d/)(?[^/>\s]+))", DefaultOptions); - private static readonly string[] Scopes = { DriveService.Scope.DriveReadonly }; - private static readonly string ApplicationName = "RPCS3 Compatibility Bot 2.0"; + if (string.IsNullOrEmpty(message.Content)) + return (null, null); - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + if (!File.Exists(Config.GoogleApiConfigPath)) + return (null, null); + + var matches = ExternalLink.Matches(message.Content); + if (matches.Count == 0) + return (null, null); + + var client = GetClient(); + foreach (Match m in matches) { - if (string.IsNullOrEmpty(message.Content)) - return (null, null); - - if (!File.Exists(Config.GoogleApiConfigPath)) - return (null, null); - - var matches = ExternalLink.Matches(message.Content); - if (matches.Count == 0) - return (null, null); - - var client = GetClient(); - foreach (Match m in matches) + try { - try + if (m.Groups["gdrive_id"].Value is string fid + && !string.IsNullOrEmpty(fid)) { - if (m.Groups["gdrive_id"].Value is string fid - && !string.IsNullOrEmpty(fid)) + var fileInfoRequest = client.Files.Get(fid); + fileInfoRequest.Fields = "name, size, kind"; + var fileMeta = await fileInfoRequest.ExecuteAsync(Config.Cts.Token).ConfigureAwait(false); + if (fileMeta.Kind == "drive#file" && fileMeta.Size > 0) { - var fileInfoRequest = client.Files.Get(fid); - fileInfoRequest.Fields = "name, size, kind"; - var fileMeta = await fileInfoRequest.ExecuteAsync(Config.Cts.Token).ConfigureAwait(false); - if (fileMeta.Kind == "drive#file" && fileMeta.Size > 0) + var buf = BufferPool.Rent(SnoopBufferSize); + try { - var buf = BufferPool.Rent(SnoopBufferSize); - try + int read; + await using (var stream = new MemoryStream(buf, true)) { - int read; - await using (var stream = new MemoryStream(buf, true)) - { - var limit = Math.Min(SnoopBufferSize, fileMeta.Size.Value) - 1; - var progress = await fileInfoRequest.DownloadRangeAsync(stream, new RangeHeaderValue(0, limit), Config.Cts.Token).ConfigureAwait(false); - if (progress.Status != DownloadStatus.Completed) - continue; + var limit = Math.Min(SnoopBufferSize, fileMeta.Size.Value) - 1; + var progress = await fileInfoRequest.DownloadRangeAsync(stream, new RangeHeaderValue(0, limit), Config.Cts.Token).ConfigureAwait(false); + if (progress.Status != DownloadStatus.Completed) + continue; - read = (int)progress.BytesDownloaded; - } - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(fileMeta.Name, (int)fileMeta.Size, buf.AsSpan(0, read)); - if (canHandle) - return (new GoogleDriveSource(fileInfoRequest, fileMeta, handler), null); - else if (!string.IsNullOrEmpty(reason)) - return(null, reason); - } + read = (int)progress.BytesDownloaded; } - finally + foreach (var handler in handlers) { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(fileMeta.Name, (int)fileMeta.Size, buf.AsSpan(0, read)); + if (canHandle) + return (new GoogleDriveSource(fileInfoRequest, fileMeta, handler), null); + else if (!string.IsNullOrEmpty(reason)) + return(null, reason); } } + finally + { + BufferPool.Return(buf); + } } } - catch (Exception e) - { - Config.Log.Warn(e, $"Error sniffing {m.Groups["gdrive_link"].Value}"); - } } - return (null, null); + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["gdrive_link"].Value}"); + } + } + return (null, null); + } + + private static DriveService GetClient() + { + var credential = GoogleCredential.FromFile(Config.GoogleApiConfigPath).CreateScoped(Scopes); + var service = new DriveService(new BaseClientService.Initializer() + { + HttpClientInitializer = credential, + ApplicationName = ApplicationName, + }); + return service; + } + + private sealed class GoogleDriveSource : ISource + { + public string SourceType => "Google Drive"; + public string FileName => fileMeta.Name; + public long SourceFileSize => fileMeta.Size ?? 0; + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + private readonly FilesResource.GetRequest fileInfoRequest; + private readonly FileMeta fileMeta; + private readonly IArchiveHandler handler; + + public GoogleDriveSource(FilesResource.GetRequest fileInfoRequest, FileMeta fileMeta, IArchiveHandler handler) + { + this.fileInfoRequest = fileInfoRequest; + this.fileMeta = fileMeta; + this.handler = handler; } - private static DriveService GetClient() + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) { - var credential = GoogleCredential.FromFile(Config.GoogleApiConfigPath).CreateScoped(Scopes); - var service = new DriveService(new BaseClientService.Initializer() + try { - HttpClientInitializer = credential, - ApplicationName = ApplicationName, - }); - return service; - } - - private sealed class GoogleDriveSource : ISource - { - public string SourceType => "Google Drive"; - public string FileName => fileMeta.Name; - public long SourceFileSize => fileMeta.Size ?? 0; - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - private readonly FilesResource.GetRequest fileInfoRequest; - private readonly FileMeta fileMeta; - private readonly IArchiveHandler handler; - - public GoogleDriveSource(FilesResource.GetRequest fileInfoRequest, FileMeta fileMeta, IArchiveHandler handler) - { - this.fileInfoRequest = fileInfoRequest; - this.fileMeta = fileMeta; - this.handler = handler; + var pipe = new Pipe(); + await using var pushStream = pipe.Writer.AsStream(); + var progressTask = fileInfoRequest.DownloadAsync(pushStream, cancellationToken); + await using var pullStream = pipe.Reader.AsStream(); + var pipingTask = handler.FillPipeAsync(pullStream, writer, cancellationToken); + var result = await progressTask.ConfigureAwait(false); + if (result.Status != DownloadStatus.Completed) + Config.Log.Error(result.Exception, "Failed to download file from Google Drive: " + result.Status); + await pipe.Writer.FlushAsync(cancellationToken).ConfigureAwait(false); + await pipe.Writer.CompleteAsync().ConfigureAwait(false); + await pipingTask.ConfigureAwait(false); } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) + catch (Exception e) { - try - { - var pipe = new Pipe(); - await using var pushStream = pipe.Writer.AsStream(); - var progressTask = fileInfoRequest.DownloadAsync(pushStream, cancellationToken); - await using var pullStream = pipe.Reader.AsStream(); - var pipingTask = handler.FillPipeAsync(pullStream, writer, cancellationToken); - var result = await progressTask.ConfigureAwait(false); - if (result.Status != DownloadStatus.Completed) - Config.Log.Error(result.Exception, "Failed to download file from Google Drive: " + result.Status); - await pipe.Writer.FlushAsync(cancellationToken).ConfigureAwait(false); - await pipe.Writer.CompleteAsync().ConfigureAwait(false); - await pipingTask.ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to download file from Google Drive"); - } + Config.Log.Error(e, "Failed to download file from Google Drive"); } } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/ISourceHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/ISourceHandler.cs index 6c22df1e..96101017 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/ISourceHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/ISourceHandler.cs @@ -5,20 +5,19 @@ using System.Threading.Tasks; using CompatBot.EventHandlers.LogParsing.ArchiveHandlers; using DSharpPlus.Entities; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers -{ - public interface ISourceHandler - { - Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers); - } +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; - public interface ISource - { - string SourceType { get; } - string FileName { get; } - long SourceFileSize { get; } - long SourceFilePosition { get; } - long LogFileSize { get; } - Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken); - } +public interface ISourceHandler +{ + Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers); } + +public interface ISource +{ + string SourceType { get; } + string FileName { get; } + long SourceFileSize { get; } + long SourceFilePosition { get; } + long LogFileSize { get; } + Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/MediafireHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/MediafireHandler.cs index 2729d5c2..a773f16a 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/MediafireHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/MediafireHandler.cs @@ -11,105 +11,104 @@ using System.Text; using System.Threading; using MediafireClient; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class MediafireHandler : BaseSourceHandler { - internal sealed class MediafireHandler : BaseSourceHandler + //http://www.mediafire.com/file/tmybrjpmtrpcejl/DemonsSouls_CrashLog_Nov.19th.zip/file + private static readonly Regex ExternalLink = new(@"(?(https?://)?(www\.)?mediafire\.com/file/(?[^/\s]+)/(?[^/\?\s]+)(/file)?)", DefaultOptions); + private static readonly Client Client = new(); + + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - //http://www.mediafire.com/file/tmybrjpmtrpcejl/DemonsSouls_CrashLog_Nov.19th.zip/file - private static readonly Regex ExternalLink = new(@"(?(https?://)?(www\.)?mediafire\.com/file/(?[^/\s]+)/(?[^/\?\s]+)(/file)?)", DefaultOptions); - private static readonly Client Client = new(); + if (string.IsNullOrEmpty(message.Content)) + return (null, null); - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + var matches = ExternalLink.Matches(message.Content); + if (matches.Count == 0) + return (null, null); + + using var client = HttpClientFactory.Create(); + foreach (Match m in matches) { - if (string.IsNullOrEmpty(message.Content)) - return (null, null); - - var matches = ExternalLink.Matches(message.Content); - if (matches.Count == 0) - return (null, null); - - using var client = HttpClientFactory.Create(); - foreach (Match m in matches) + if (m.Groups["mediafire_link"].Value is string lnk + && !string.IsNullOrEmpty(lnk) + && Uri.TryCreate(lnk, UriKind.Absolute, out var webLink)) { - if (m.Groups["mediafire_link"].Value is string lnk - && !string.IsNullOrEmpty(lnk) - && Uri.TryCreate(lnk, UriKind.Absolute, out var webLink)) + try { + var filename = m.Groups["filename"].Value; + var filesize = -1; + + Config.Log.Debug($"Trying to get download link for {webLink}..."); + var directLink = await Client.GetDirectDownloadLinkAsync(webLink, Config.Cts.Token).ConfigureAwait(false); + if (directLink is null) + return (null, null); + + Config.Log.Debug($"Trying to get content size for {directLink}..."); + using (var request = new HttpRequestMessage(HttpMethod.Head, directLink)) + { + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Config.Cts.Token); + if (response.Content.Headers.ContentLength > 0) + filesize = (int)response.Content.Headers.ContentLength.Value; + if (response.Content.Headers.ContentDisposition?.FileName is string fname && !string.IsNullOrEmpty(fname)) + filename = fname; + } + + Config.Log.Debug($"Trying to get content stream for {directLink}..."); + await using var stream = await client.GetStreamAsync(directLink).ConfigureAwait(false); + var buf = BufferPool.Rent(SnoopBufferSize); try { - var filename = m.Groups["filename"].Value; - var filesize = -1; - - Config.Log.Debug($"Trying to get download link for {webLink}..."); - var directLink = await Client.GetDirectDownloadLinkAsync(webLink, Config.Cts.Token).ConfigureAwait(false); - if (directLink is null) - return (null, null); - - Config.Log.Debug($"Trying to get content size for {directLink}..."); - using (var request = new HttpRequestMessage(HttpMethod.Head, directLink)) + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + foreach (var handler in handlers) { - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Config.Cts.Token); - if (response.Content.Headers.ContentLength > 0) - filesize = (int)response.Content.Headers.ContentLength.Value; - if (response.Content.Headers.ContentDisposition?.FileName is string fname && !string.IsNullOrEmpty(fname)) - filename = fname; - } - - Config.Log.Debug($"Trying to get content stream for {directLink}..."); - await using var stream = await client.GetStreamAsync(directLink).ConfigureAwait(false); - var buf = BufferPool.Rent(SnoopBufferSize); - try - { - var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); - if (canHandle) - return (new MediafireSource(directLink, handler, filename, filesize), null); - else if (!string.IsNullOrEmpty(reason)) - return (null, reason); - } - Config.Log.Debug("MediaFire Response:\n" + Encoding.UTF8.GetString(buf, 0, read)); - } - finally - { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); + if (canHandle) + return (new MediafireSource(directLink, handler, filename, filesize), null); + else if (!string.IsNullOrEmpty(reason)) + return (null, reason); } + Config.Log.Debug("MediaFire Response:\n" + Encoding.UTF8.GetString(buf, 0, read)); } - catch (Exception e) + finally { - Config.Log.Warn(e, $"Error sniffing {m.Groups["mediafire_link"].Value}"); + BufferPool.Return(buf); } } + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["mediafire_link"].Value}"); + } } - return (null, null); + } + return (null, null); + } + + private sealed class MediafireSource : ISource + { + private readonly Uri? uri; + private readonly IArchiveHandler handler; + + public string SourceType => "Mediafire"; + public string FileName { get; } + public long SourceFileSize { get; } + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + internal MediafireSource(Uri? uri, IArchiveHandler handler, string fileName, int fileSize) + { + this.uri = uri; + this.handler = handler; + FileName = fileName; + SourceFileSize = fileSize; } - private sealed class MediafireSource : ISource + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) { - private readonly Uri? uri; - private readonly IArchiveHandler handler; - - public string SourceType => "Mediafire"; - public string FileName { get; } - public long SourceFileSize { get; } - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - internal MediafireSource(Uri? uri, IArchiveHandler handler, string fileName, int fileSize) - { - this.uri = uri; - this.handler = handler; - FileName = fileName; - SourceFileSize = fileSize; - } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - using var client = HttpClientFactory.Create(); - await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); - } + using var client = HttpClientFactory.Create(); + await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/MegaHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/MegaHandler.cs index 1f72b1d4..496936b7 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/MegaHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/MegaHandler.cs @@ -9,93 +9,92 @@ using CompatBot.Utils; using System.IO.Pipelines; using System.Threading; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class MegaHandler : BaseSourceHandler { - internal sealed class MegaHandler : BaseSourceHandler + // mega.nz/#!8IJHBYyB!jw21m-GCs85uzj9E5XRysqyJCsNfZS0Zx4Eu9_zvuUM + // mega.nz/file/8IJHBYyB#jw21m-GCs85uzj9E5XRysqyJCsNfZS0Zx4Eu9_zvuUM + private static readonly Regex ExternalLink = new(@"(?(https?://)?mega(\.co)?\.nz/(#(?[^/>\s]+)|file/(?[^/>\s]+)))", DefaultOptions); + private static readonly IProgress Doodad = new Progress(_ => { }); + + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - // mega.nz/#!8IJHBYyB!jw21m-GCs85uzj9E5XRysqyJCsNfZS0Zx4Eu9_zvuUM - // mega.nz/file/8IJHBYyB#jw21m-GCs85uzj9E5XRysqyJCsNfZS0Zx4Eu9_zvuUM - private static readonly Regex ExternalLink = new(@"(?(https?://)?mega(\.co)?\.nz/(#(?[^/>\s]+)|file/(?[^/>\s]+)))", DefaultOptions); - private static readonly IProgress Doodad = new Progress(_ => { }); + if (string.IsNullOrEmpty(message.Content)) + return (null, null); - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + var matches = ExternalLink.Matches(message.Content); + if (matches.Count == 0) + return (null, null); + + var client = new MegaApiClient(); + await client.LoginAnonymousAsync(); + foreach (Match m in matches) { - if (string.IsNullOrEmpty(message.Content)) - return (null, null); - - var matches = ExternalLink.Matches(message.Content); - if (matches.Count == 0) - return (null, null); - - var client = new MegaApiClient(); - await client.LoginAnonymousAsync(); - foreach (Match m in matches) + try { - try + if (m.Groups["mega_link"].Value is string lnk + && !string.IsNullOrEmpty(lnk) + && Uri.TryCreate(lnk, UriKind.Absolute, out var uri)) { - if (m.Groups["mega_link"].Value is string lnk - && !string.IsNullOrEmpty(lnk) - && Uri.TryCreate(lnk, UriKind.Absolute, out var uri)) + var node = await client.GetNodeFromLinkAsync(uri).ConfigureAwait(false); + if (node.Type == NodeType.File) { - var node = await client.GetNodeFromLinkAsync(uri).ConfigureAwait(false); - if (node.Type == NodeType.File) + var buf = BufferPool.Rent(SnoopBufferSize); + try { - var buf = BufferPool.Rent(SnoopBufferSize); - try + int read; + await using (var stream = await client.DownloadAsync(uri, Doodad, Config.Cts.Token).ConfigureAwait(false)) + read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + foreach (var handler in handlers) { - int read; - await using (var stream = await client.DownloadAsync(uri, Doodad, Config.Cts.Token).ConfigureAwait(false)) - read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(node.Name, (int)node.Size, buf.AsSpan(0, read)); - if (canHandle) - return (new MegaSource(client, uri, node, handler), null); - else if (!string.IsNullOrEmpty(reason)) - return (null, reason); - } - } - finally - { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(node.Name, (int)node.Size, buf.AsSpan(0, read)); + if (canHandle) + return (new MegaSource(client, uri, node, handler), null); + else if (!string.IsNullOrEmpty(reason)) + return (null, reason); } } + finally + { + BufferPool.Return(buf); + } } } - catch (Exception e) - { - Config.Log.Warn(e, $"Error sniffing {m.Groups["mega_link"].Value}"); - } } - return (null, null); + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["mega_link"].Value}"); + } + } + return (null, null); + } + + private sealed class MegaSource : ISource + { + private readonly IMegaApiClient client; + private readonly Uri uri; + private readonly INode node; + private readonly IArchiveHandler handler; + + public string SourceType => "Mega"; + public string FileName => node.Name; + public long SourceFileSize => node.Size; + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + internal MegaSource(IMegaApiClient client, Uri uri, INode node, IArchiveHandler handler) + { + this.client = client; + this.uri = uri; + this.node = node; + this.handler = handler; } - private sealed class MegaSource : ISource + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) { - private readonly IMegaApiClient client; - private readonly Uri uri; - private readonly INode node; - private readonly IArchiveHandler handler; - - public string SourceType => "Mega"; - public string FileName => node.Name; - public long SourceFileSize => node.Size; - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - internal MegaSource(IMegaApiClient client, Uri uri, INode node, IArchiveHandler handler) - { - this.client = client; - this.uri = uri; - this.node = node; - this.handler = handler; - } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - await using var stream = await client.DownloadAsync(uri, Doodad, cancellationToken).ConfigureAwait(false); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); - } + await using var stream = await client.DownloadAsync(uri, Doodad, cancellationToken).ConfigureAwait(false); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/OneDriveSourceHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/OneDriveSourceHandler.cs index d40e6c51..8b7de336 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/OneDriveSourceHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/OneDriveSourceHandler.cs @@ -11,98 +11,97 @@ using DSharpPlus.Entities; using OneDriveClient; using OneDriveClient.POCOs; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class OneDriveSourceHandler : BaseSourceHandler { - internal sealed class OneDriveSourceHandler : BaseSourceHandler + private static readonly Regex ExternalLink = new(@"(?(https?://)?(1drv\.ms|onedrive\.live\.com)/[^>\s]+)", DefaultOptions); + private static readonly Client Client = new(); + + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - private static readonly Regex ExternalLink = new(@"(?(https?://)?(1drv\.ms|onedrive\.live\.com)/[^>\s]+)", DefaultOptions); - private static readonly Client Client = new(); + if (string.IsNullOrEmpty(message.Content)) + return (null, null); - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + var matches = ExternalLink.Matches(message.Content); + if (matches.Count == 0) + return (null, null); + + using var httpClient = HttpClientFactory.Create(); + foreach (Match m in matches) { - if (string.IsNullOrEmpty(message.Content)) - return (null, null); - - var matches = ExternalLink.Matches(message.Content); - if (matches.Count == 0) - return (null, null); - - using var httpClient = HttpClientFactory.Create(); - foreach (Match m in matches) + try { - try + if (m.Groups["onedrive_link"].Value is string lnk + && !string.IsNullOrEmpty(lnk) + && Uri.TryCreate(lnk, UriKind.Absolute, out var uri) + && await Client.ResolveContentLinkAsync(uri, Config.Cts.Token).ConfigureAwait(false) is DriveItemMeta itemMeta + && itemMeta.ContentDownloadUrl is string downloadUrl) { - if (m.Groups["onedrive_link"].Value is string lnk - && !string.IsNullOrEmpty(lnk) - && Uri.TryCreate(lnk, UriKind.Absolute, out var uri) - && await Client.ResolveContentLinkAsync(uri, Config.Cts.Token).ConfigureAwait(false) is DriveItemMeta itemMeta - && itemMeta.ContentDownloadUrl is string downloadUrl) + try { + var filename = itemMeta.Name ?? ""; + var filesize = itemMeta.Size; + uri = new Uri(downloadUrl); + + await using var stream = await httpClient.GetStreamAsync(uri).ConfigureAwait(false); + var buf = BufferPool.Rent(SnoopBufferSize); try { - var filename = itemMeta.Name ?? ""; - var filesize = itemMeta.Size; - uri = new Uri(downloadUrl); - - await using var stream = await httpClient.GetStreamAsync(uri).ConfigureAwait(false); - var buf = BufferPool.Rent(SnoopBufferSize); - try + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + foreach (var handler in handlers) { - var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); - if (canHandle) - return (new OneDriveSource(uri, handler, filename, filesize), null); - else if (!string.IsNullOrEmpty(reason)) - return (null, reason); - } - } - finally - { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); + if (canHandle) + return (new OneDriveSource(uri, handler, filename, filesize), null); + else if (!string.IsNullOrEmpty(reason)) + return (null, reason); } } - catch (Exception e) + finally { - Config.Log.Warn(e, $"Error sniffing {m.Groups["link"].Value}"); + BufferPool.Return(buf); } } - } - catch (Exception e) - { - Config.Log.Warn(e, $"Error sniffing {m.Groups["mega_link"].Value}"); + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["link"].Value}"); + } } } - return (null, null); + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["mega_link"].Value}"); + } + } + return (null, null); + } + + + private sealed class OneDriveSource : ISource + { + private readonly Uri uri; + private readonly IArchiveHandler handler; + + public string SourceType => "OneDrive"; + public string FileName { get; } + public long SourceFileSize { get; } + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + internal OneDriveSource(Uri uri, IArchiveHandler handler, string fileName, int fileSize) + { + this.uri = uri; + this.handler = handler; + FileName = fileName; + SourceFileSize = fileSize; } - - private sealed class OneDriveSource : ISource + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) { - private readonly Uri uri; - private readonly IArchiveHandler handler; - - public string SourceType => "OneDrive"; - public string FileName { get; } - public long SourceFileSize { get; } - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - internal OneDriveSource(Uri uri, IArchiveHandler handler, string fileName, int fileSize) - { - this.uri = uri; - this.handler = handler; - FileName = fileName; - SourceFileSize = fileSize; - } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - using var client = HttpClientFactory.Create(); - await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); - } + using var client = HttpClientFactory.Create(); + await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/PastebinHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/PastebinHandler.cs index 9f2832a7..144a0415 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/PastebinHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/PastebinHandler.cs @@ -9,85 +9,84 @@ using System.IO.Pipelines; using System.Net.Http; using System.Threading; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class PastebinHandler : BaseSourceHandler { - internal sealed class PastebinHandler : BaseSourceHandler + private static readonly Regex ExternalLink = new(@"(?(https?://)pastebin.com/(raw/)?(?[^/>\s]+))", DefaultOptions); + + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - private static readonly Regex ExternalLink = new(@"(?(https?://)pastebin.com/(raw/)?(?[^/>\s]+))", DefaultOptions); + if (string.IsNullOrEmpty(message.Content)) + return (null, null); - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + var matches = ExternalLink.Matches(message.Content); + if (matches.Count == 0) + return (null, null); + + using var client = HttpClientFactory.Create(); + foreach (Match m in matches) { - if (string.IsNullOrEmpty(message.Content)) - return (null, null); - - var matches = ExternalLink.Matches(message.Content); - if (matches.Count == 0) - return (null, null); - - using var client = HttpClientFactory.Create(); - foreach (Match m in matches) + try { - try + if (m.Groups["pastebin_id"].Value is string pid + && !string.IsNullOrEmpty(pid)) { - if (m.Groups["pastebin_id"].Value is string pid - && !string.IsNullOrEmpty(pid)) + var uri = new Uri("https://pastebin.com/raw/" + pid); + await using var stream = await client.GetStreamAsync(uri).ConfigureAwait(false); + var buf = BufferPool.Rent(SnoopBufferSize); + try { - var uri = new Uri("https://pastebin.com/raw/" + pid); - await using var stream = await client.GetStreamAsync(uri).ConfigureAwait(false); - var buf = BufferPool.Rent(SnoopBufferSize); - try + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + var filename = pid + ".log"; + var filesize = stream.CanSeek ? (int)stream.Length : 0; + foreach (var handler in handlers) { - var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - var filename = pid + ".log"; - var filesize = stream.CanSeek ? (int)stream.Length : 0; - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); - if (canHandle) - return (new PastebinSource(uri, filename, filesize, handler), null); - else if (!string.IsNullOrEmpty(reason)) - return (null, reason); - } - } - finally - { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); + if (canHandle) + return (new PastebinSource(uri, filename, filesize, handler), null); + else if (!string.IsNullOrEmpty(reason)) + return (null, reason); } } - } - catch (Exception e) - { - Config.Log.Warn(e, $"Error sniffing {m.Groups["mega_link"].Value}"); + finally + { + BufferPool.Return(buf); + } } } - return (null, null); + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["mega_link"].Value}"); + } + } + return (null, null); + } + + private sealed class PastebinSource : ISource + { + private readonly Uri uri; + private readonly IArchiveHandler handler; + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + public PastebinSource(Uri uri, string filename, int filesize, IArchiveHandler handler) + { + this.uri = uri; + FileName = filename; + SourceFileSize = filesize; + this.handler = handler; } - private sealed class PastebinSource : ISource + public string SourceType => "Pastebin"; + public string FileName { get; } + public long SourceFileSize { get; } + + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) { - private readonly Uri uri; - private readonly IArchiveHandler handler; - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - public PastebinSource(Uri uri, string filename, int filesize, IArchiveHandler handler) - { - this.uri = uri; - FileName = filename; - SourceFileSize = filesize; - this.handler = handler; - } - - public string SourceType => "Pastebin"; - public string FileName { get; } - public long SourceFileSize { get; } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - using var client = HttpClientFactory.Create(); - await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); - } + using var client = HttpClientFactory.Create(); + await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsing/SourceHandlers/YandexDiskHandler.cs b/CompatBot/EventHandlers/LogParsing/SourceHandlers/YandexDiskHandler.cs index f608fbd9..79a55371 100644 --- a/CompatBot/EventHandlers/LogParsing/SourceHandlers/YandexDiskHandler.cs +++ b/CompatBot/EventHandlers/LogParsing/SourceHandlers/YandexDiskHandler.cs @@ -10,98 +10,97 @@ using System.Net.Http; using System.Threading; using YandexDiskClient; -namespace CompatBot.EventHandlers.LogParsing.SourceHandlers +namespace CompatBot.EventHandlers.LogParsing.SourceHandlers; + +internal sealed class YandexDiskHandler: BaseSourceHandler { - internal sealed class YandexDiskHandler: BaseSourceHandler + private static readonly Regex ExternalLink = new(@"(?(https?://)?(www\.)?yadi\.sk/d/(?[^/>\s]+))\b", DefaultOptions); + private static readonly Client Client = new(); + + public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) { - private static readonly Regex ExternalLink = new(@"(?(https?://)?(www\.)?yadi\.sk/d/(?[^/>\s]+))\b", DefaultOptions); - private static readonly Client Client = new(); + if (string.IsNullOrEmpty(message.Content)) + return (null, null); - public override async Task<(ISource? source, string? failReason)> FindHandlerAsync(DiscordMessage message, ICollection handlers) + var matches = ExternalLink.Matches(message.Content); + if (matches.Count == 0) + return (null, null); + + using var client = HttpClientFactory.Create(); + foreach (Match m in matches) { - if (string.IsNullOrEmpty(message.Content)) - return (null, null); - - var matches = ExternalLink.Matches(message.Content); - if (matches.Count == 0) - return (null, null); - - using var client = HttpClientFactory.Create(); - foreach (Match m in matches) + if (m.Groups["yadisk_link"].Value is string lnk + && !string.IsNullOrEmpty(lnk) + && Uri.TryCreate(lnk, UriKind.Absolute, out var webLink)) { - if (m.Groups["yadisk_link"].Value is string lnk - && !string.IsNullOrEmpty(lnk) - && Uri.TryCreate(lnk, UriKind.Absolute, out var webLink)) + try { + var filename = ""; + var filesize = -1; + + var resourceInfo = await Client.GetResourceInfoAsync(webLink, Config.Cts.Token).ConfigureAwait(false); + if (string.IsNullOrEmpty(resourceInfo?.File)) + return (null, null); + + if (resourceInfo.Size.HasValue) + filesize = resourceInfo.Size.Value; + if (!string.IsNullOrEmpty(resourceInfo.Name)) + filename = resourceInfo.Name; + + await using var stream = await client.GetStreamAsync(resourceInfo.File).ConfigureAwait(false); + var buf = BufferPool.Rent(SnoopBufferSize); try { - var filename = ""; - var filesize = -1; - - var resourceInfo = await Client.GetResourceInfoAsync(webLink, Config.Cts.Token).ConfigureAwait(false); - if (string.IsNullOrEmpty(resourceInfo?.File)) - return (null, null); - - if (resourceInfo.Size.HasValue) - filesize = resourceInfo.Size.Value; - if (!string.IsNullOrEmpty(resourceInfo.Name)) - filename = resourceInfo.Name; - - await using var stream = await client.GetStreamAsync(resourceInfo.File).ConfigureAwait(false); - var buf = BufferPool.Rent(SnoopBufferSize); - try + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + foreach (var handler in handlers) { - var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); - foreach (var handler in handlers) - { - var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); - if (canHandle) - return (new YaDiskSource(resourceInfo.File, handler, filename, filesize), null); - else if (!string.IsNullOrEmpty(reason)) - return (null, reason); - } - } - finally - { - BufferPool.Return(buf); + var (canHandle, reason) = handler.CanHandle(filename, filesize, buf.AsSpan(0, read)); + if (canHandle) + return (new YaDiskSource(resourceInfo.File, handler, filename, filesize), null); + else if (!string.IsNullOrEmpty(reason)) + return (null, reason); } } - - catch (Exception e) + finally { - Config.Log.Warn(e, $"Error sniffing {m.Groups["yadisk_link"].Value}"); + BufferPool.Return(buf); } } - } - return (null, null); - } - private sealed class YaDiskSource : ISource - { - private readonly Uri uri; - private readonly IArchiveHandler handler; - - public string SourceType => "Ya.Disk"; - public string FileName { get; } - public long SourceFileSize { get; } - public long SourceFilePosition => handler.SourcePosition; - public long LogFileSize => handler.LogSize; - - internal YaDiskSource(string uri, IArchiveHandler handler, string fileName, int fileSize) - { - this.uri = new Uri(uri); - this.handler = handler; - FileName = fileName; - SourceFileSize = fileSize; - } - - public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) - { - using var client = HttpClientFactory.Create(); - await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); - await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); + catch (Exception e) + { + Config.Log.Warn(e, $"Error sniffing {m.Groups["yadisk_link"].Value}"); + } } } - + return (null, null); } + + private sealed class YaDiskSource : ISource + { + private readonly Uri uri; + private readonly IArchiveHandler handler; + + public string SourceType => "Ya.Disk"; + public string FileName { get; } + public long SourceFileSize { get; } + public long SourceFilePosition => handler.SourcePosition; + public long LogFileSize => handler.LogSize; + + internal YaDiskSource(string uri, IArchiveHandler handler, string fileName, int fileSize) + { + this.uri = new Uri(uri); + this.handler = handler; + FileName = fileName; + SourceFileSize = fileSize; + } + + public async Task FillPipeAsync(PipeWriter writer, CancellationToken cancellationToken) + { + using var client = HttpClientFactory.Create(); + await using var stream = await client.GetStreamAsync(uri, cancellationToken).ConfigureAwait(false); + await handler.FillPipeAsync(stream, writer, cancellationToken).ConfigureAwait(false); + } + } + } \ No newline at end of file diff --git a/CompatBot/EventHandlers/LogParsingHandler.cs b/CompatBot/EventHandlers/LogParsingHandler.cs index d28d455c..1f259c8b 100644 --- a/CompatBot/EventHandlers/LogParsingHandler.cs +++ b/CompatBot/EventHandlers/LogParsingHandler.cs @@ -23,377 +23,376 @@ using CompatBot.EventHandlers.LogParsing.SourceHandlers; using CompatBot.Utils.Extensions; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +public static class LogParsingHandler { - public static class LogParsingHandler + private static readonly char[] LinkSeparator = { ' ', '>', '\r', '\n' }; + private static readonly ISourceHandler[] SourceHandlers = { - private static readonly char[] LinkSeparator = { ' ', '>', '\r', '\n' }; - private static readonly ISourceHandler[] SourceHandlers = - { - new DiscordAttachmentHandler(), - new GoogleDriveHandler(), - new DropboxHandler(), - new MegaHandler(), - new OneDriveSourceHandler(), - new YandexDiskHandler(), - new MediafireHandler(), - new GenericLinkHandler(), - new PastebinHandler(), - }; - private static readonly IArchiveHandler[] ArchiveHandlers = - { - new GzipHandler(), - new ZipHandler(), - new RarHandler(), - new SevenZipHandler(), - new PlainTextHandler(), - }; + new DiscordAttachmentHandler(), + new GoogleDriveHandler(), + new DropboxHandler(), + new MegaHandler(), + new OneDriveSourceHandler(), + new YandexDiskHandler(), + new MediafireHandler(), + new GenericLinkHandler(), + new PastebinHandler(), + }; + private static readonly IArchiveHandler[] ArchiveHandlers = + { + new GzipHandler(), + new ZipHandler(), + new RarHandler(), + new SevenZipHandler(), + new PlainTextHandler(), + }; - private static readonly SemaphoreSlim QueueLimiter = new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2)); - private delegate void OnLog(DiscordClient client, DiscordChannel channel, DiscordMessage message, DiscordMember? requester = null, bool checkExternalLinks = false, bool force = false); - private static event OnLog OnNewLog = EnqueueLogProcessing; + private static readonly SemaphoreSlim QueueLimiter = new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2)); + private delegate void OnLog(DiscordClient client, DiscordChannel channel, DiscordMessage message, DiscordMember? requester = null, bool checkExternalLinks = false, bool force = false); + private static event OnLog OnNewLog = EnqueueLogProcessing; - public static Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) - { - var message = args.Message; - if (message.Author.IsBotSafeCheck()) - return Task.CompletedTask; - - if (!string.IsNullOrEmpty(message.Content) - && (message.Content.StartsWith(Config.CommandPrefix) - || message.Content.StartsWith(Config.AutoRemoveCommandPrefix))) - return Task.CompletedTask; - - var isSpamChannel = LimitedToSpamChannel.IsSpamChannel(args.Channel); - var isHelpChannel = "help".Equals(args.Channel.Name, StringComparison.OrdinalIgnoreCase) - || "donors".Equals(args.Channel.Name, StringComparison.OrdinalIgnoreCase); - var checkExternalLinks = isHelpChannel || isSpamChannel; - OnNewLog(c, args.Channel, args.Message, checkExternalLinks: checkExternalLinks); + public static Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) + { + var message = args.Message; + if (message.Author.IsBotSafeCheck()) return Task.CompletedTask; - } - public static async void EnqueueLogProcessing(DiscordClient client, DiscordChannel channel, DiscordMessage message, DiscordMember? requester = null, bool checkExternalLinks = false, bool force = false) + if (!string.IsNullOrEmpty(message.Content) + && (message.Content.StartsWith(Config.CommandPrefix) + || message.Content.StartsWith(Config.AutoRemoveCommandPrefix))) + return Task.CompletedTask; + + var isSpamChannel = LimitedToSpamChannel.IsSpamChannel(args.Channel); + var isHelpChannel = "help".Equals(args.Channel.Name, StringComparison.OrdinalIgnoreCase) + || "donors".Equals(args.Channel.Name, StringComparison.OrdinalIgnoreCase); + var checkExternalLinks = isHelpChannel || isSpamChannel; + OnNewLog(c, args.Channel, args.Message, checkExternalLinks: checkExternalLinks); + return Task.CompletedTask; + } + + public static async void EnqueueLogProcessing(DiscordClient client, DiscordChannel channel, DiscordMessage message, DiscordMember? requester = null, bool checkExternalLinks = false, bool force = false) + { + var start = DateTimeOffset.UtcNow; + try { - var start = DateTimeOffset.UtcNow; + if (!QueueLimiter.Wait(0)) + { + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, TimeSpan.Zero, HttpStatusCode.TooManyRequests.ToString(), false); + await channel.SendMessageAsync("Log processing is rate limited, try again a bit later").ConfigureAwait(false); + return; + } + + var parsedLog = false; + var startTime = Stopwatch.StartNew(); + DiscordMessage? botMsg = null; try { - if (!QueueLimiter.Wait(0)) + var possibleHandlers = SourceHandlers.Select(h => h.FindHandlerAsync(message, ArchiveHandlers).ConfigureAwait(false).GetAwaiter().GetResult()).ToList(); + var source = possibleHandlers.FirstOrDefault(h => h.source != null).source; + var fail = possibleHandlers.FirstOrDefault(h => !string.IsNullOrEmpty(h.failReason)).failReason; + + var isSpamChannel = LimitedToSpamChannel.IsSpamChannel(channel); + var isHelpChannel = "help".Equals(channel.Name, StringComparison.OrdinalIgnoreCase) + || "donors".Equals(channel.Name, StringComparison.OrdinalIgnoreCase); + + if (source != null) { - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, TimeSpan.Zero, HttpStatusCode.TooManyRequests.ToString(), false); - await channel.SendMessageAsync("Log processing is rate limited, try again a bit later").ConfigureAwait(false); - return; - } + Config.Log.Debug($">>>>>>> {message.Id % 100} Parsing log '{source.FileName}' from {message.Author.Username}#{message.Author.Discriminator} ({message.Author.Id}) using {source.GetType().Name} ({source.SourceFileSize} bytes)..."); + var analyzingProgressEmbed = GetAnalyzingMsgEmbed(client); + var msgBuilder = new DiscordMessageBuilder() + .WithEmbed(analyzingProgressEmbed.AddAuthor(client, message, source)) + .WithReply(message.Id); + botMsg = await channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); + parsedLog = true; - var parsedLog = false; - var startTime = Stopwatch.StartNew(); - DiscordMessage? botMsg = null; - try - { - var possibleHandlers = SourceHandlers.Select(h => h.FindHandlerAsync(message, ArchiveHandlers).ConfigureAwait(false).GetAwaiter().GetResult()).ToList(); - var source = possibleHandlers.FirstOrDefault(h => h.source != null).source; - var fail = possibleHandlers.FirstOrDefault(h => !string.IsNullOrEmpty(h.failReason)).failReason; - - var isSpamChannel = LimitedToSpamChannel.IsSpamChannel(channel); - var isHelpChannel = "help".Equals(channel.Name, StringComparison.OrdinalIgnoreCase) - || "donors".Equals(channel.Name, StringComparison.OrdinalIgnoreCase); - - if (source != null) + LogParseState? result = null, tmpResult; + using (var timeout = new CancellationTokenSource(Config.LogParsingTimeoutInSec)) { - Config.Log.Debug($">>>>>>> {message.Id % 100} Parsing log '{source.FileName}' from {message.Author.Username}#{message.Author.Discriminator} ({message.Author.Id}) using {source.GetType().Name} ({source.SourceFileSize} bytes)..."); - var analyzingProgressEmbed = GetAnalyzingMsgEmbed(client); - var msgBuilder = new DiscordMessageBuilder() - .WithEmbed(analyzingProgressEmbed.AddAuthor(client, message, source)) - .WithReply(message.Id); - botMsg = await channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); - parsedLog = true; - - LogParseState? result = null, tmpResult; - using (var timeout = new CancellationTokenSource(Config.LogParsingTimeoutInSec)) + using var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, Config.Cts.Token); + var tries = 0; + do { - using var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, Config.Cts.Token); - var tries = 0; - do - { - tmpResult = await ParseLogAsync( - source, - async () => botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, embed: analyzingProgressEmbed.AddAuthor(client, message, source)).ConfigureAwait(false), - combinedTokenSource.Token - ).ConfigureAwait(false); - result ??= tmpResult; - tries++; - } while ((tmpResult == null || tmpResult.Error == LogParseState.ErrorCode.UnknownError) && !combinedTokenSource.IsCancellationRequested && tries < 3); - } - if (result == null) - { - botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, embed: new DiscordEmbedBuilder - { - Description = "Log analysis failed, most likely cause is a truncated/invalid log.\n" + - "Please run the game again and re-upload a new copy.", - Color = Config.Colors.LogResultFailed, - } - .AddAuthor(client, message, source) - .Build() + tmpResult = await ParseLogAsync( + source, + async () => botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, embed: analyzingProgressEmbed.AddAuthor(client, message, source)).ConfigureAwait(false), + combinedTokenSource.Token ).ConfigureAwait(false); - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.InternalServerError.ToString(), false); - } - else - { - result.ParsingTime = startTime.Elapsed; - try + result ??= tmpResult; + tries++; + } while ((tmpResult == null || tmpResult.Error == LogParseState.ErrorCode.UnknownError) && !combinedTokenSource.IsCancellationRequested && tries < 3); + } + if (result == null) + { + botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, embed: new DiscordEmbedBuilder { - if (result.Error == LogParseState.ErrorCode.PiracyDetected) + Description = "Log analysis failed, most likely cause is a truncated/invalid log.\n" + + "Please run the game again and re-upload a new copy.", + Color = Config.Colors.LogResultFailed, + } + .AddAuthor(client, message, source) + .Build() + ).ConfigureAwait(false); + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.InternalServerError.ToString(), false); + } + else + { + result.ParsingTime = startTime.Elapsed; + try + { + if (result.Error == LogParseState.ErrorCode.PiracyDetected) + { + if (result.SelectedFilter is null) { - if (result.SelectedFilter is null) + Config.Log.Error("Piracy was detectedin log, but no trigger provided"); + result.SelectedFilter = new Piracystring { - Config.Log.Error("Piracy was detectedin log, but no trigger provided"); - result.SelectedFilter = new Piracystring - { - String = "Unknown trigger, plz kick 13xforever", - Actions = FilterAction.IssueWarning | FilterAction.RemoveContent, - Context = FilterContext.Log, - }; - } - var yarr = client.GetEmoji(":piratethink:", "☠"); - result.ReadBytes = 0; - if (message.Author.IsWhitelisted(client, channel.Guild)) - { - var piracyWarning = await result.AsEmbedAsync(client, message, source).ConfigureAwait(false); - piracyWarning = piracyWarning.WithDescription("Please remove the log and issue warning to the original author of the log"); - botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, embed: piracyWarning).ConfigureAwait(false); - var matchedOn = ContentFilter.GetMatchedScope(result.SelectedFilter, result.SelectedFilterContext); - await client.ReportAsync(yarr + " Pirated Release (whitelisted by role)", message, result.SelectedFilter.String, matchedOn, result.SelectedFilter.Id, result.SelectedFilterContext, ReportSeverity.Low).ConfigureAwait(false); - } - else - { - var severity = ReportSeverity.Low; - try - { - DeletedMessagesMonitor.RemovedByBotCache.Set(message.Id, true, DeletedMessagesMonitor.CacheRetainTime); - await message.DeleteAsync("Piracy detected in log").ConfigureAwait(false); - } - catch (Exception e) - { - severity = ReportSeverity.High; - Config.Log.Warn(e, $"Unable to delete message in {channel.Name}"); - } - try - { - botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, - $"{message.Author.Mention}, please read carefully:\n" + - "🏴‍☠️ **Pirated content detected** 🏴‍☠️\n" + - "__You are being denied further support until you legally dump the game__.\n" + - "Please note that the RPCS3 community and its developers do not support piracy.\n" + - "Most of the issues with pirated dumps occur due to them being modified in some way " + - "that prevent them from working on RPCS3.\n" + - "If you need help obtaining valid working dump of the game you own, please read the quickstart guide at " - ).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to send piracy warning"); - } - try - { - var matchedOn = ContentFilter.GetMatchedScope(result.SelectedFilter, result.SelectedFilterContext); - await client.ReportAsync(yarr + " Pirated Release", message, result.SelectedFilter.String, matchedOn, result.SelectedFilter.Id, result.SelectedFilterContext, severity).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to send piracy report"); - } - if (!(message.Channel.IsPrivate || (message.Channel.Name?.Contains("spam") ?? true))) - await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Pirated Release", $"{result.SelectedFilter.String} - {result.SelectedFilterContext?.Sanitize()}"); - } + String = "Unknown trigger, plz kick 13xforever", + Actions = FilterAction.IssueWarning | FilterAction.RemoveContent, + Context = FilterContext.Log, + }; + } + var yarr = client.GetEmoji(":piratethink:", "☠"); + result.ReadBytes = 0; + if (message.Author.IsWhitelisted(client, channel.Guild)) + { + var piracyWarning = await result.AsEmbedAsync(client, message, source).ConfigureAwait(false); + piracyWarning = piracyWarning.WithDescription("Please remove the log and issue warning to the original author of the log"); + botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, embed: piracyWarning).ConfigureAwait(false); + var matchedOn = ContentFilter.GetMatchedScope(result.SelectedFilter, result.SelectedFilterContext); + await client.ReportAsync(yarr + " Pirated Release (whitelisted by role)", message, result.SelectedFilter.String, matchedOn, result.SelectedFilter.Id, result.SelectedFilterContext, ReportSeverity.Low).ConfigureAwait(false); } else { - if (result.SelectedFilter != null) + var severity = ReportSeverity.Low; + try { - var ignoreFlags = FilterAction.IssueWarning | FilterAction.SendMessage | FilterAction.ShowExplain; - await ContentFilter.PerformFilterActions(client, message, result.SelectedFilter, ignoreFlags, result.SelectedFilterContext!).ConfigureAwait(false); + DeletedMessagesMonitor.RemovedByBotCache.Set(message.Id, true, DeletedMessagesMonitor.CacheRetainTime); + await message.DeleteAsync("Piracy detected in log").ConfigureAwait(false); } - - if (!force && string.IsNullOrEmpty(message.Content) && !isSpamChannel) + catch (Exception e) { - var threshold = DateTime.UtcNow.AddMinutes(-15); - var previousMessages = await channel.GetMessagesBeforeCachedAsync(message.Id).ConfigureAwait(false); - previousMessages = previousMessages.TakeWhile((msg, num) => num < 15 || msg.Timestamp.UtcDateTime > threshold).ToList(); - if (!previousMessages.Any(m => m.Author == message.Author && !string.IsNullOrEmpty(m.Content))) + severity = ReportSeverity.High; + Config.Log.Warn(e, $"Unable to delete message in {channel.Name}"); + } + try + { + botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, + $"{message.Author.Mention}, please read carefully:\n" + + "🏴‍☠️ **Pirated content detected** 🏴‍☠️\n" + + "__You are being denied further support until you legally dump the game__.\n" + + "Please note that the RPCS3 community and its developers do not support piracy.\n" + + "Most of the issues with pirated dumps occur due to them being modified in some way " + + "that prevent them from working on RPCS3.\n" + + "If you need help obtaining valid working dump of the game you own, please read the quickstart guide at " + ).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e, "Failed to send piracy warning"); + } + try + { + var matchedOn = ContentFilter.GetMatchedScope(result.SelectedFilter, result.SelectedFilterContext); + await client.ReportAsync(yarr + " Pirated Release", message, result.SelectedFilter.String, matchedOn, result.SelectedFilter.Id, result.SelectedFilterContext, severity).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e, "Failed to send piracy report"); + } + if (!(message.Channel.IsPrivate || (message.Channel.Name?.Contains("spam") ?? true))) + await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Pirated Release", $"{result.SelectedFilter.String} - {result.SelectedFilterContext?.Sanitize()}"); + } + } + else + { + if (result.SelectedFilter != null) + { + var ignoreFlags = FilterAction.IssueWarning | FilterAction.SendMessage | FilterAction.ShowExplain; + await ContentFilter.PerformFilterActions(client, message, result.SelectedFilter, ignoreFlags, result.SelectedFilterContext!).ConfigureAwait(false); + } + + if (!force && string.IsNullOrEmpty(message.Content) && !isSpamChannel) + { + var threshold = DateTime.UtcNow.AddMinutes(-15); + var previousMessages = await channel.GetMessagesBeforeCachedAsync(message.Id).ConfigureAwait(false); + previousMessages = previousMessages.TakeWhile((msg, num) => num < 15 || msg.Timestamp.UtcDateTime > threshold).ToList(); + if (!previousMessages.Any(m => m.Author == message.Author && !string.IsNullOrEmpty(m.Content))) + { + var botSpamChannel = await client.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); + if (isHelpChannel) + await botMsg.UpdateOrCreateMessageAsync( + channel, + $"{message.Author.Mention} please describe the issue if you require help, " + + $"or upload log in {botSpamChannel.Mention} if you only need to check your logs automatically" + ).ConfigureAwait(false); + else { - var botSpamChannel = await client.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); - if (isHelpChannel) + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.NoContent.ToString(), true); + var helpChannel = channel.Guild.Channels.Values.FirstOrDefault(ch => ch.Type == ChannelType.Text && "help".Equals(ch.Name)); + if (helpChannel != null) await botMsg.UpdateOrCreateMessageAsync( channel, - $"{message.Author.Mention} please describe the issue if you require help, " + + $"{message.Author.Mention} if you require help, please ask in {helpChannel.Mention}, and describe your issue first, " + $"or upload log in {botSpamChannel.Mention} if you only need to check your logs automatically" ).ConfigureAwait(false); - else - { - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.NoContent.ToString(), true); - var helpChannel = channel.Guild.Channels.Values.FirstOrDefault(ch => ch.Type == ChannelType.Text && "help".Equals(ch.Name)); - if (helpChannel != null) - await botMsg.UpdateOrCreateMessageAsync( - channel, - $"{message.Author.Mention} if you require help, please ask in {helpChannel.Mention}, and describe your issue first, " + - $"or upload log in {botSpamChannel.Mention} if you only need to check your logs automatically" - ).ConfigureAwait(false); - } - return; } + return; } - - botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, - //requester == null ? null : $"Analyzed log from {client.GetMember(channel.Guild, message.Author)?.GetUsernameWithNickname()} by request from {requester.Mention}:", - embed: await result.AsEmbedAsync(client, message, source).ConfigureAwait(false) - ).ConfigureAwait(false); } - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.OK.ToString(), true); - } - catch (Exception e) - { - Config.Log.Error(e, "Sending log results failed"); + + botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, + //requester == null ? null : $"Analyzed log from {client.GetMember(channel.Guild, message.Author)?.GetUsernameWithNickname()} by request from {requester.Mention}:", + embed: await result.AsEmbedAsync(client, message, source).ConfigureAwait(false) + ).ConfigureAwait(false); } + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.OK.ToString(), true); } - return; - } - else if (!string.IsNullOrEmpty(fail) - && (isHelpChannel || isSpamChannel)) - { - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.InternalServerError.ToString(), false); - await channel.SendMessageAsync($"{message.Author.Mention} {fail}").ConfigureAwait(false); - return; - } - - var potentialLogExtension = message.Attachments.Select(a => Path.GetExtension(a.FileName).ToUpperInvariant().TrimStart('.')).FirstOrDefault(); - switch (potentialLogExtension) - { - case "TXT": + catch (Exception e) { - await channel.SendMessageAsync($"{message.Author.Mention} Please upload the full RPCS3.log.gz (or RPCS3.log with a zip/rar icon) file after closing the emulator instead of copying the logs from RPCS3's interface, as it doesn't contain all the required information.").ConfigureAwait(false); - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.BadRequest.ToString(), true); - return; + Config.Log.Error(e, "Sending log results failed"); } } + return; + } + else if (!string.IsNullOrEmpty(fail) + && (isHelpChannel || isSpamChannel)) + { + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.InternalServerError.ToString(), false); + await channel.SendMessageAsync($"{message.Author.Mention} {fail}").ConfigureAwait(false); + return; + } - if (string.IsNullOrEmpty(message.Content)) + var potentialLogExtension = message.Attachments.Select(a => Path.GetExtension(a.FileName).ToUpperInvariant().TrimStart('.')).FirstOrDefault(); + switch (potentialLogExtension) + { + case "TXT": { - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.NoContent.ToString(), true); + await channel.SendMessageAsync($"{message.Author.Mention} Please upload the full RPCS3.log.gz (or RPCS3.log with a zip/rar icon) file after closing the emulator instead of copying the logs from RPCS3's interface, as it doesn't contain all the required information.").ConfigureAwait(false); + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.BadRequest.ToString(), true); return; } - - var linkStart = message.Content.IndexOf("http", StringComparison.Ordinal); - if (linkStart > -1) - { - var link = message.Content[linkStart..].Split(LinkSeparator, 2)[0]; - if (link.Contains(".log", StringComparison.InvariantCultureIgnoreCase) || link.Contains("rpcs3.zip", StringComparison.CurrentCultureIgnoreCase)) - { - await channel.SendMessageAsync("If you intended to upload a log file please re-upload it directly to discord").ConfigureAwait(false); - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.BadRequest.ToString(), true); - } - } } - finally + + if (string.IsNullOrEmpty(message.Content)) { - QueueLimiter.Release(); - if (parsedLog) - Config.Log.Debug($"<<<<<<< {message.Id % 100} Finished parsing in {startTime.Elapsed}"); + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.NoContent.ToString(), true); + return; + } + + var linkStart = message.Content.IndexOf("http", StringComparison.Ordinal); + if (linkStart > -1) + { + var link = message.Content[linkStart..].Split(LinkSeparator, 2)[0]; + if (link.Contains(".log", StringComparison.InvariantCultureIgnoreCase) || link.Contains("rpcs3.zip", StringComparison.CurrentCultureIgnoreCase)) + { + await channel.SendMessageAsync("If you intended to upload a log file please re-upload it directly to discord").ConfigureAwait(false); + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.BadRequest.ToString(), true); + } } } - catch (Exception e) + finally { - Config.Log.Error(e, "Error parsing log"); - Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.InternalServerError.ToString(), false); - Config.TelemetryClient?.TrackException(e); + QueueLimiter.Release(); + if (parsedLog) + Config.Log.Debug($"<<<<<<< {message.Id % 100} Finished parsing in {startTime.Elapsed}"); } } - - public static async Task ParseLogAsync(ISource source, Func onProgressAsync, CancellationToken cancellationToken) + catch (Exception e) { - LogParseState? result = null; - try - { - try - { - var pipe = new Pipe(); - var fillPipeTask = source.FillPipeAsync(pipe.Writer, cancellationToken); - var readPipeTask = LogParser.ReadPipeAsync(pipe.Reader, cancellationToken); - do - { - await Task.WhenAny(readPipeTask, Task.Delay(5000, cancellationToken)).ConfigureAwait(false); - if (!readPipeTask.IsCompleted) - await onProgressAsync().ConfigureAwait(false); - } while (!readPipeTask.IsCompleted && !cancellationToken.IsCancellationRequested); - result = await readPipeTask.ConfigureAwait(false); - await fillPipeTask.ConfigureAwait(false); - } - catch (Exception pre) - { - if (!(pre is OperationCanceledException)) - Config.Log.Error(pre); - if (result == null) - throw; - } - - result.TotalBytes = source.LogFileSize; - if (result.FilterTriggers.Any()) - { - var (f, c) = result.FilterTriggers.Values.FirstOrDefault(ft => ft.filter.Actions.HasFlag(FilterAction.IssueWarning)); - if (f == null) - (f, c) = result.FilterTriggers.Values.FirstOrDefault(ft => ft.filter.Actions.HasFlag(FilterAction.RemoveContent)); - if (f == null) - (f, c) = result.FilterTriggers.Values.FirstOrDefault(); - result.SelectedFilter = f; - result.SelectedFilterContext = c; - } -#if DEBUG - Config.Log.Debug("~~~~~~~~~~~~~~~~~~~~"); - Config.Log.Debug("Extractor hit stats (CPU time, s / total hits):"); - foreach (var (key, (count, time)) in result.ExtractorHitStats.OrderByDescending(kvp => kvp.Value.regexTime)) - { - var ttime = TimeSpan.FromTicks(time).TotalSeconds; - var msg = $"{ttime:0.000}/{count} ({ttime/count:0.000000}): {key}"; - if (count > 100000 || ttime > 20) - Config.Log.Fatal(msg); - else if (count > 10000 || ttime > 10) - Config.Log.Error(msg); - else if (count > 1000 || ttime > 5) - Config.Log.Warn(msg); - else if (count > 100 || ttime > 1) - Config.Log.Info(msg); - else - Config.Log.Debug(msg); - } - - Config.Log.Debug("~~~~~~~~~~~~~~~~~~~~"); - Config.Log.Debug("Syscall stats:"); - int serialCount = result.Syscalls.Count, functionCount = 0; - foreach (var funcStats in result.Syscalls.Values) - functionCount += funcStats.Count; - Config.Log.Debug("Product keys: " + serialCount); - Config.Log.Debug("Functions: " + functionCount); - Config.Log.Debug("Saving syscall information..."); - var sw = Stopwatch.StartNew(); -#endif - await SyscallInfoProvider.SaveAsync(result.Syscalls).ConfigureAwait(false); -#if DEBUG - Config.Log.Debug("Saving syscall information took " + sw.Elapsed); -#endif - } - catch (Exception e) - { - Config.Log.Error(e, "Log parsing failed"); - } - return result; - } - - private static DiscordEmbedBuilder GetAnalyzingMsgEmbed(DiscordClient client) - { - var indicator = client.GetEmoji(":kannamag:", Config.Reactions.PleaseWait); - return new DiscordEmbedBuilder - { - Description = $"{indicator} Looking at the log, please wait... {indicator}", - Color = Config.Colors.LogUnknown, - }; + Config.Log.Error(e, "Error parsing log"); + Config.TelemetryClient?.TrackRequest(nameof(LogParsingHandler), start, DateTimeOffset.UtcNow - start, HttpStatusCode.InternalServerError.ToString(), false); + Config.TelemetryClient?.TrackException(e); } } -} + + public static async Task ParseLogAsync(ISource source, Func onProgressAsync, CancellationToken cancellationToken) + { + LogParseState? result = null; + try + { + try + { + var pipe = new Pipe(); + var fillPipeTask = source.FillPipeAsync(pipe.Writer, cancellationToken); + var readPipeTask = LogParser.ReadPipeAsync(pipe.Reader, cancellationToken); + do + { + await Task.WhenAny(readPipeTask, Task.Delay(5000, cancellationToken)).ConfigureAwait(false); + if (!readPipeTask.IsCompleted) + await onProgressAsync().ConfigureAwait(false); + } while (!readPipeTask.IsCompleted && !cancellationToken.IsCancellationRequested); + result = await readPipeTask.ConfigureAwait(false); + await fillPipeTask.ConfigureAwait(false); + } + catch (Exception pre) + { + if (!(pre is OperationCanceledException)) + Config.Log.Error(pre); + if (result == null) + throw; + } + + result.TotalBytes = source.LogFileSize; + if (result.FilterTriggers.Any()) + { + var (f, c) = result.FilterTriggers.Values.FirstOrDefault(ft => ft.filter.Actions.HasFlag(FilterAction.IssueWarning)); + if (f == null) + (f, c) = result.FilterTriggers.Values.FirstOrDefault(ft => ft.filter.Actions.HasFlag(FilterAction.RemoveContent)); + if (f == null) + (f, c) = result.FilterTriggers.Values.FirstOrDefault(); + result.SelectedFilter = f; + result.SelectedFilterContext = c; + } +#if DEBUG + Config.Log.Debug("~~~~~~~~~~~~~~~~~~~~"); + Config.Log.Debug("Extractor hit stats (CPU time, s / total hits):"); + foreach (var (key, (count, time)) in result.ExtractorHitStats.OrderByDescending(kvp => kvp.Value.regexTime)) + { + var ttime = TimeSpan.FromTicks(time).TotalSeconds; + var msg = $"{ttime:0.000}/{count} ({ttime/count:0.000000}): {key}"; + if (count > 100000 || ttime > 20) + Config.Log.Fatal(msg); + else if (count > 10000 || ttime > 10) + Config.Log.Error(msg); + else if (count > 1000 || ttime > 5) + Config.Log.Warn(msg); + else if (count > 100 || ttime > 1) + Config.Log.Info(msg); + else + Config.Log.Debug(msg); + } + + Config.Log.Debug("~~~~~~~~~~~~~~~~~~~~"); + Config.Log.Debug("Syscall stats:"); + int serialCount = result.Syscalls.Count, functionCount = 0; + foreach (var funcStats in result.Syscalls.Values) + functionCount += funcStats.Count; + Config.Log.Debug("Product keys: " + serialCount); + Config.Log.Debug("Functions: " + functionCount); + Config.Log.Debug("Saving syscall information..."); + var sw = Stopwatch.StartNew(); +#endif + await SyscallInfoProvider.SaveAsync(result.Syscalls).ConfigureAwait(false); +#if DEBUG + Config.Log.Debug("Saving syscall information took " + sw.Elapsed); +#endif + } + catch (Exception e) + { + Config.Log.Error(e, "Log parsing failed"); + } + return result; + } + + private static DiscordEmbedBuilder GetAnalyzingMsgEmbed(DiscordClient client) + { + var indicator = client.GetEmoji(":kannamag:", Config.Reactions.PleaseWait); + return new DiscordEmbedBuilder + { + Description = $"{indicator} Looking at the log, please wait... {indicator}", + Color = Config.Colors.LogUnknown, + }; + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/MediaScreenshotMonitor.cs b/CompatBot/EventHandlers/MediaScreenshotMonitor.cs index 9a1f1028..cc0075a2 100644 --- a/CompatBot/EventHandlers/MediaScreenshotMonitor.cs +++ b/CompatBot/EventHandlers/MediaScreenshotMonitor.cs @@ -15,29 +15,29 @@ using DSharpPlus.EventArgs; using Microsoft.Azure.CognitiveServices.Vision.ComputerVision; using Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal sealed class MediaScreenshotMonitor { - internal sealed class MediaScreenshotMonitor + private readonly DiscordClient client; + private readonly ComputerVisionClient cvClient = new(new ApiKeyServiceClientCredentials(Config.AzureComputerVisionKey)) {Endpoint = Config.AzureComputerVisionEndpoint}; + private readonly SemaphoreSlim workSemaphore = new(0); + private readonly ConcurrentQueue<(MessageCreateEventArgs evt, Guid readOperationId)> workQueue = new ConcurrentQueue<(MessageCreateEventArgs args, Guid readOperationId)>(); + public static int MaxQueueLength { get; private set; } + + internal MediaScreenshotMonitor(DiscordClient client) { - private readonly DiscordClient client; - private readonly ComputerVisionClient cvClient = new(new ApiKeyServiceClientCredentials(Config.AzureComputerVisionKey)) {Endpoint = Config.AzureComputerVisionEndpoint}; - private readonly SemaphoreSlim workSemaphore = new(0); - private readonly ConcurrentQueue<(MessageCreateEventArgs evt, Guid readOperationId)> workQueue = new ConcurrentQueue<(MessageCreateEventArgs args, Guid readOperationId)>(); - public static int MaxQueueLength { get; private set; } + this.client = client; + } - internal MediaScreenshotMonitor(DiscordClient client) - { - this.client = client; - } + public async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs evt) + { + var message = evt.Message; + if (message == null) + return; - public async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs evt) - { - var message = evt.Message; - if (message == null) - return; - - if (!Config.Moderation.OcrChannels.Contains(evt.Channel.Id)) - return; + if (!Config.Moderation.OcrChannels.Contains(evt.Channel.Id)) + return; #if !DEBUG if (message.Author.IsBotSafeCheck()) @@ -47,117 +47,116 @@ namespace CompatBot.EventHandlers return; #endif - if (!message.Attachments.Any()) - return; + if (!message.Attachments.Any()) + return; - var images = Vision.GetImageAttachments(message).Select(att => att.Url) - .Concat(Vision.GetImagesFromEmbeds(message)) - .ToList(); - var tasks = new List>(images.Count); - foreach (var url in images) - tasks.Add(cvClient.ReadAsync(url, cancellationToken: Config.Cts.Token)); - foreach (var t in tasks) + var images = Vision.GetImageAttachments(message).Select(att => att.Url) + .Concat(Vision.GetImagesFromEmbeds(message)) + .ToList(); + var tasks = new List>(images.Count); + foreach (var url in images) + tasks.Add(cvClient.ReadAsync(url, cancellationToken: Config.Cts.Token)); + foreach (var t in tasks) + { + try { - try - { - var headers = await t.ConfigureAwait(false); - workQueue.Enqueue((evt, new(new Uri(headers.OperationLocation).Segments.Last()))); - workSemaphore.Release(); - } - catch (Exception ex) - { - Config.Log.Warn(ex, "Failed to create a new text recognition task"); - } + var headers = await t.ConfigureAwait(false); + workQueue.Enqueue((evt, new(new Uri(headers.OperationLocation).Segments.Last()))); + workSemaphore.Release(); + } + catch (Exception ex) + { + Config.Log.Warn(ex, "Failed to create a new text recognition task"); } } + } - public async Task ProcessWorkQueue() + public async Task ProcessWorkQueue() + { + if (string.IsNullOrEmpty(Config.AzureComputerVisionKey)) + return; + + Guid? reEnqueueId = null; + do { - if (string.IsNullOrEmpty(Config.AzureComputerVisionKey)) + await workSemaphore.WaitAsync(Config.Cts.Token).ConfigureAwait(false); + if (Config.Cts.IsCancellationRequested) return; - Guid? reEnqueueId = null; - do + MaxQueueLength = Math.Max(MaxQueueLength, workQueue.Count); + if (!workQueue.TryDequeue(out var item)) + continue; + + if (item.readOperationId == reEnqueueId) { - await workSemaphore.WaitAsync(Config.Cts.Token).ConfigureAwait(false); + await Task.Delay(100).ConfigureAwait(false); + reEnqueueId = null; if (Config.Cts.IsCancellationRequested) return; + } - MaxQueueLength = Math.Max(MaxQueueLength, workQueue.Count); - if (!workQueue.TryDequeue(out var item)) - continue; - - if (item.readOperationId == reEnqueueId) + try + { + var result = await cvClient.GetReadResultAsync(item.readOperationId, Config.Cts.Token).ConfigureAwait(false); + if (result.Status == OperationStatusCodes.Succeeded) { - await Task.Delay(100).ConfigureAwait(false); - reEnqueueId = null; - if (Config.Cts.IsCancellationRequested) - return; - } - - try - { - var result = await cvClient.GetReadResultAsync(item.readOperationId, Config.Cts.Token).ConfigureAwait(false); - if (result.Status == OperationStatusCodes.Succeeded) + if (result.AnalyzeResult?.ReadResults?.SelectMany(r => r.Lines).Any() ?? false) { - if (result.AnalyzeResult?.ReadResults?.SelectMany(r => r.Lines).Any() ?? false) + var cnt = true; + var prefix = $"[{item.evt.Message.Id % 100:00}]"; + var ocrTextBuf = new StringBuilder($"OCR result of message <{item.evt.Message.JumpLink}>:").AppendLine(); + Config.Log.Debug($"{prefix} OCR result of message {item.evt.Message.JumpLink}:"); + var duplicates = new HashSet(); + foreach (var r in result.AnalyzeResult.ReadResults) + foreach (var l in r.Lines) { - var cnt = true; - var prefix = $"[{item.evt.Message.Id % 100:00}]"; - var ocrTextBuf = new StringBuilder($"OCR result of message <{item.evt.Message.JumpLink}>:").AppendLine(); - Config.Log.Debug($"{prefix} OCR result of message {item.evt.Message.JumpLink}:"); - var duplicates = new HashSet(); - foreach (var r in result.AnalyzeResult.ReadResults) - foreach (var l in r.Lines) + ocrTextBuf.AppendLine(l.Text.Sanitize()); + Config.Log.Debug($"{prefix} {l.Text}"); + if (cnt + && await ContentFilter.FindTriggerAsync(FilterContext.Chat, l.Text).ConfigureAwait(false) is Piracystring hit + && duplicates.Add(hit.String)) { - ocrTextBuf.AppendLine(l.Text.Sanitize()); - Config.Log.Debug($"{prefix} {l.Text}"); - if (cnt - && await ContentFilter.FindTriggerAsync(FilterContext.Chat, l.Text).ConfigureAwait(false) is Piracystring hit - && duplicates.Add(hit.String)) - { - FilterAction suppressFlags = 0; - if ("media".Equals(item.evt.Channel.Name)) - suppressFlags = FilterAction.SendMessage | FilterAction.ShowExplain; - await ContentFilter.PerformFilterActions( - client, - item.evt.Message, - hit, - suppressFlags, - l.Text, - "🖼 Screenshot of an undesirable content", - "Screenshot of an undesirable content" - ).ConfigureAwait(false); - cnt &= !hit.Actions.HasFlag(FilterAction.RemoveContent) && !hit.Actions.HasFlag(FilterAction.IssueWarning); - } + FilterAction suppressFlags = 0; + if ("media".Equals(item.evt.Channel.Name)) + suppressFlags = FilterAction.SendMessage | FilterAction.ShowExplain; + await ContentFilter.PerformFilterActions( + client, + item.evt.Message, + hit, + suppressFlags, + l.Text, + "🖼 Screenshot of an undesirable content", + "Screenshot of an undesirable content" + ).ConfigureAwait(false); + cnt &= !hit.Actions.HasFlag(FilterAction.RemoveContent) && !hit.Actions.HasFlag(FilterAction.IssueWarning); } - var ocrText = ocrTextBuf.ToString(); - var hasVkDiagInfo = ocrText.Contains("Vulkan Diagnostics Tool v") - || ocrText.Contains("VkDiag Version:"); - if (!cnt || hasVkDiagInfo) - try - { - var botSpamCh = await client.GetChannelAsync(Config.ThumbnailSpamId).ConfigureAwait(false); - await botSpamCh.SendAutosplitMessageAsync(ocrTextBuf, blockStart: "", blockEnd: "").ConfigureAwait(false); - } - catch (Exception ex) - { - Config.Log.Warn(ex); - } } - } - else if (result.Status == OperationStatusCodes.NotStarted || result.Status == OperationStatusCodes.Running) - { - workQueue.Enqueue(item); - reEnqueueId ??= item.readOperationId; - workSemaphore.Release(); + var ocrText = ocrTextBuf.ToString(); + var hasVkDiagInfo = ocrText.Contains("Vulkan Diagnostics Tool v") + || ocrText.Contains("VkDiag Version:"); + if (!cnt || hasVkDiagInfo) + try + { + var botSpamCh = await client.GetChannelAsync(Config.ThumbnailSpamId).ConfigureAwait(false); + await botSpamCh.SendAutosplitMessageAsync(ocrTextBuf, blockStart: "", blockEnd: "").ConfigureAwait(false); + } + catch (Exception ex) + { + Config.Log.Warn(ex); + } } } - catch (Exception e) + else if (result.Status == OperationStatusCodes.NotStarted || result.Status == OperationStatusCodes.Running) { - Config.Log.Warn(e); + workQueue.Enqueue(item); + reEnqueueId ??= item.readOperationId; + workSemaphore.Release(); } - } while (!Config.Cts.IsCancellationRequested); - } + } + catch (Exception e) + { + Config.Log.Warn(e); + } + } while (!Config.Cts.IsCancellationRequested); } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/NewBuildsMonitor.cs b/CompatBot/EventHandlers/NewBuildsMonitor.cs index 76699929..79a3fdf4 100644 --- a/CompatBot/EventHandlers/NewBuildsMonitor.cs +++ b/CompatBot/EventHandlers/NewBuildsMonitor.cs @@ -9,82 +9,81 @@ using DSharpPlus; using DSharpPlus.Entities; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class NewBuildsMonitor { - internal static class NewBuildsMonitor + private static readonly Regex BuildResult = new(@"\[rpcs3:master\] \d+ new commit", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly TimeSpan PassiveCheckInterval = TimeSpan.FromMinutes(20); + private static readonly TimeSpan ActiveCheckInterval = TimeSpan.FromMinutes(1); + private static readonly TimeSpan ActiveCheckResetThreshold = TimeSpan.FromMinutes(10); + private static readonly ConcurrentQueue<(DateTime start, DateTime end)> ExpectedNewBuildTimeFrames = new(); + + public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) { - private static readonly Regex BuildResult = new(@"\[rpcs3:master\] \d+ new commit", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); - private static readonly TimeSpan PassiveCheckInterval = TimeSpan.FromMinutes(20); - private static readonly TimeSpan ActiveCheckInterval = TimeSpan.FromMinutes(1); - private static readonly TimeSpan ActiveCheckResetThreshold = TimeSpan.FromMinutes(10); - private static readonly ConcurrentQueue<(DateTime start, DateTime end)> ExpectedNewBuildTimeFrames = new(); - - public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) + if (args.Author.IsBotSafeCheck() + && !args.Author.IsCurrent + && "github".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase) + && args.Message.Embeds.FirstOrDefault() is DiscordEmbed embed + && !string.IsNullOrEmpty(embed.Title) + && BuildResult.IsMatch(embed.Title) + ) { - if (args.Author.IsBotSafeCheck() - && !args.Author.IsCurrent - && "github".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase) - && args.Message.Embeds.FirstOrDefault() is DiscordEmbed embed - && !string.IsNullOrEmpty(embed.Title) - && BuildResult.IsMatch(embed.Title) - ) - { - Config.Log.Info("Found new PR merge message"); - var azureClient = Config.GetAzureDevOpsClient(); - var pipelineDurationStats = await azureClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false); - var expectedMean = DateTime.UtcNow + pipelineDurationStats.Mean; - var start = expectedMean - pipelineDurationStats.StdDev; - var end = expectedMean + pipelineDurationStats.StdDev + ActiveCheckResetThreshold; - ExpectedNewBuildTimeFrames.Enqueue((start, end)); - } + Config.Log.Info("Found new PR merge message"); + var azureClient = Config.GetAzureDevOpsClient(); + var pipelineDurationStats = await azureClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false); + var expectedMean = DateTime.UtcNow + pipelineDurationStats.Mean; + var start = expectedMean - pipelineDurationStats.StdDev; + var end = expectedMean + pipelineDurationStats.StdDev + ActiveCheckResetThreshold; + ExpectedNewBuildTimeFrames.Enqueue((start, end)); } + } - public static async Task MonitorAsync(DiscordClient client) - { - var lastCheck = DateTime.UtcNow.AddDays(-1); - Exception? lastException = null; - while (!Config.Cts.IsCancellationRequested) - { - var now = DateTime.UtcNow; - var checkInterval = PassiveCheckInterval; - (DateTime start, DateTime end) nearestBuildCheckInterval = default; - while (!ExpectedNewBuildTimeFrames.IsEmpty - && ExpectedNewBuildTimeFrames.TryPeek(out nearestBuildCheckInterval) - && nearestBuildCheckInterval.end < now) - { - ExpectedNewBuildTimeFrames.TryDequeue(out _); - } - if (nearestBuildCheckInterval.start < now && now < nearestBuildCheckInterval.end) - checkInterval = ActiveCheckInterval; - if (lastCheck + checkInterval < now) - { - try - { - await CompatList.UpdatesCheck.CheckForRpcs3Updates(client, null).ConfigureAwait(false); - lastCheck = DateTime.UtcNow; - } - catch (Exception e) - { - if (e.GetType() != lastException?.GetType()) - { - Config.Log.Debug(e); - lastException = e; - } - } - } - await Task.Delay(1000, Config.Cts.Token).ConfigureAwait(false); - } - } - - internal static void Reset() + public static async Task MonitorAsync(DiscordClient client) + { + var lastCheck = DateTime.UtcNow.AddDays(-1); + Exception? lastException = null; + while (!Config.Cts.IsCancellationRequested) { var now = DateTime.UtcNow; - if (!ExpectedNewBuildTimeFrames.IsEmpty - && ExpectedNewBuildTimeFrames.TryPeek(out var ebci) - && ebci.start < now) + var checkInterval = PassiveCheckInterval; + (DateTime start, DateTime end) nearestBuildCheckInterval = default; + while (!ExpectedNewBuildTimeFrames.IsEmpty + && ExpectedNewBuildTimeFrames.TryPeek(out nearestBuildCheckInterval) + && nearestBuildCheckInterval.end < now) { ExpectedNewBuildTimeFrames.TryDequeue(out _); } + if (nearestBuildCheckInterval.start < now && now < nearestBuildCheckInterval.end) + checkInterval = ActiveCheckInterval; + if (lastCheck + checkInterval < now) + { + try + { + await CompatList.UpdatesCheck.CheckForRpcs3Updates(client, null).ConfigureAwait(false); + lastCheck = DateTime.UtcNow; + } + catch (Exception e) + { + if (e.GetType() != lastException?.GetType()) + { + Config.Log.Debug(e); + lastException = e; + } + } + } + await Task.Delay(1000, Config.Cts.Token).ConfigureAwait(false); } } -} + + internal static void Reset() + { + var now = DateTime.UtcNow; + if (!ExpectedNewBuildTimeFrames.IsEmpty + && ExpectedNewBuildTimeFrames.TryPeek(out var ebci) + && ebci.start < now) + { + ExpectedNewBuildTimeFrames.TryDequeue(out _); + } + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/PostLogHelpHandler.cs b/CompatBot/EventHandlers/PostLogHelpHandler.cs index 7922ca55..e289e748 100644 --- a/CompatBot/EventHandlers/PostLogHelpHandler.cs +++ b/CompatBot/EventHandlers/PostLogHelpHandler.cs @@ -9,62 +9,61 @@ using DSharpPlus; using DSharpPlus.EventArgs; using Microsoft.EntityFrameworkCore; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class PostLogHelpHandler { - internal static class PostLogHelpHandler + private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture; + private static readonly Regex UploadLogMention = new(@"\b((?(vul[ck][ae]n(-?1)?))|(?(post|upload|send|give)(ing)?\s+((a|the|rpcs3('s)?|your|you're|ur|my|full|game)\s+)*\blogs?))\b", DefaultOptions); + private static readonly SemaphoreSlim TheDoor = new(1, 1); + private static readonly TimeSpan ThrottlingThreshold = TimeSpan.FromSeconds(5); + private static readonly Dictionary DefaultExplanation = new() { - private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture; - private static readonly Regex UploadLogMention = new(@"\b((?(vul[ck][ae]n(-?1)?))|(?(post|upload|send|give)(ing)?\s+((a|the|rpcs3('s)?|your|you're|ur|my|full|game)\s+)*\blogs?))\b", DefaultOptions); - private static readonly SemaphoreSlim TheDoor = new(1, 1); - private static readonly TimeSpan ThrottlingThreshold = TimeSpan.FromSeconds(5); - private static readonly Dictionary DefaultExplanation = new() + ["log"] = new Explanation { Text = "To upload log, run the game, then completely close RPCS3, then drag and drop rpcs3.log.gz from the RPCS3 folder into Discord. The file may have a zip or rar icon." }, + ["vulkan-1"] = new Explanation { Text = "Please remove all the traces of video drivers using DDU, and then reinstall the latest driver version for your GPU." }, + }; + private static DateTime lastMention = DateTime.UtcNow.AddHours(-1); + + public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) + { + if (DefaultHandlerFilter.IsFluff(args.Message)) + return; + + if (!"help".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) + return; + + if (DateTime.UtcNow - lastMention < ThrottlingThreshold) + return; + + var match = UploadLogMention.Match(args.Message.Content); + if (!match.Success || string.IsNullOrEmpty(match.Groups["help"].Value)) + return; + + if (!await TheDoor.WaitAsync(0).ConfigureAwait(false)) + return; + + try { - ["log"] = new Explanation { Text = "To upload log, run the game, then completely close RPCS3, then drag and drop rpcs3.log.gz from the RPCS3 folder into Discord. The file may have a zip or rar icon." }, - ["vulkan-1"] = new Explanation { Text = "Please remove all the traces of video drivers using DDU, and then reinstall the latest driver version for your GPU." }, - }; - private static DateTime lastMention = DateTime.UtcNow.AddHours(-1); + var explanation = await GetExplanationAsync(string.IsNullOrEmpty(match.Groups["vulkan"].Value) ? "log" : "vulkan-1").ConfigureAwait(false); + var lastBotMessages = await args.Channel.GetMessagesBeforeCachedAsync(args.Message.Id, 10).ConfigureAwait(false); + foreach (var msg in lastBotMessages) + if (BotReactionsHandler.NeedToSilence(msg).needToChill + || msg.Author.IsCurrent && msg.Content == explanation.Text) + return; - public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) - { - if (DefaultHandlerFilter.IsFluff(args.Message)) - return; - - if (!"help".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) - return; - - if (DateTime.UtcNow - lastMention < ThrottlingThreshold) - return; - - var match = UploadLogMention.Match(args.Message.Content); - if (!match.Success || string.IsNullOrEmpty(match.Groups["help"].Value)) - return; - - if (!await TheDoor.WaitAsync(0).ConfigureAwait(false)) - return; - - try - { - var explanation = await GetExplanationAsync(string.IsNullOrEmpty(match.Groups["vulkan"].Value) ? "log" : "vulkan-1").ConfigureAwait(false); - var lastBotMessages = await args.Channel.GetMessagesBeforeCachedAsync(args.Message.Id, 10).ConfigureAwait(false); - foreach (var msg in lastBotMessages) - if (BotReactionsHandler.NeedToSilence(msg).needToChill - || msg.Author.IsCurrent && msg.Content == explanation.Text) - return; - - await args.Channel.SendMessageAsync(explanation.Text, explanation.Attachment, explanation.AttachmentFilename).ConfigureAwait(false); - lastMention = DateTime.UtcNow; - } - finally - { - TheDoor.Release(); - } + await args.Channel.SendMessageAsync(explanation.Text, explanation.Attachment, explanation.AttachmentFilename).ConfigureAwait(false); + lastMention = DateTime.UtcNow; } - - public static async Task GetExplanationAsync(string term) + finally { - await using var db = new BotDb(); - var result = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); - return result ?? DefaultExplanation[term]; + TheDoor.Release(); } } -} + + public static async Task GetExplanationAsync(string term) + { + await using var db = new BotDb(); + var result = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + return result ?? DefaultExplanation[term]; + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/ProductCodeLookup.cs b/CompatBot/EventHandlers/ProductCodeLookup.cs index a6f68cac..4225ce58 100644 --- a/CompatBot/EventHandlers/ProductCodeLookup.cs +++ b/CompatBot/EventHandlers/ProductCodeLookup.cs @@ -14,165 +14,164 @@ using DSharpPlus; using DSharpPlus.Entities; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class ProductCodeLookup { - internal static class ProductCodeLookup + // see http://www.psdevwiki.com/ps3/Productcode + public static readonly Regex ProductCode = new(@"(?(?:[BPSUVX][CL]|P[ETU]|NP)[AEHJKPUIX][ABJKLMPQRS]|MRTC)[ \-]?(?\d{5})", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Client CompatClient = new(); + + public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) { - // see http://www.psdevwiki.com/ps3/Productcode - public static readonly Regex ProductCode = new(@"(?(?:[BPSUVX][CL]|P[ETU]|NP)[AEHJKPUIX][ABJKLMPQRS]|MRTC)[ \-]?(?\d{5})", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Client CompatClient = new(); + if (DefaultHandlerFilter.IsFluff(args.Message)) + return; - public static async Task OnMessageCreated(DiscordClient c, MessageCreateEventArgs args) + var lastBotMessages = await args.Channel.GetMessagesBeforeAsync(args.Message.Id, 20, DateTime.UtcNow.AddSeconds(-30)).ConfigureAwait(false); + if (lastBotMessages.Any(msg => BotReactionsHandler.NeedToSilence(msg).needToChill)) + return; + + lastBotMessages = await args.Channel.GetMessagesBeforeCachedAsync(args.Message.Id, Config.ProductCodeLookupHistoryThrottle).ConfigureAwait(false); + StringBuilder? previousRepliesBuilder = null; + foreach (var msg in lastBotMessages.Where(m => m.Author.IsCurrent)) { - if (DefaultHandlerFilter.IsFluff(args.Message)) - return; - - var lastBotMessages = await args.Channel.GetMessagesBeforeAsync(args.Message.Id, 20, DateTime.UtcNow.AddSeconds(-30)).ConfigureAwait(false); - if (lastBotMessages.Any(msg => BotReactionsHandler.NeedToSilence(msg).needToChill)) - return; - - lastBotMessages = await args.Channel.GetMessagesBeforeCachedAsync(args.Message.Id, Config.ProductCodeLookupHistoryThrottle).ConfigureAwait(false); - StringBuilder? previousRepliesBuilder = null; - foreach (var msg in lastBotMessages.Where(m => m.Author.IsCurrent)) - { - previousRepliesBuilder ??= new StringBuilder(); - previousRepliesBuilder.AppendLine(msg.Content); - var embeds = msg.Embeds; - if (embeds?.Count > 0) - foreach (var embed in embeds) - previousRepliesBuilder.AppendLine(embed.Title).AppendLine(embed.Description); - } - var previousReplies = previousRepliesBuilder?.ToString() ?? ""; - - var codesToLookup = GetProductIds(args.Message.Content) - .Where(pc => !previousReplies.Contains(pc, StringComparison.InvariantCultureIgnoreCase)) - .Take(args.Channel.IsPrivate ? 50 : 5) - .ToList(); - if (codesToLookup.Count == 0) - return; - - await LookupAndPostProductCodeEmbedAsync(c, args.Message, args.Channel, codesToLookup).ConfigureAwait(false); + previousRepliesBuilder ??= new StringBuilder(); + previousRepliesBuilder.AppendLine(msg.Content); + var embeds = msg.Embeds; + if (embeds?.Count > 0) + foreach (var embed in embeds) + previousRepliesBuilder.AppendLine(embed.Title).AppendLine(embed.Description); } + var previousReplies = previousRepliesBuilder?.ToString() ?? ""; - public static async Task LookupAndPostProductCodeEmbedAsync(DiscordClient client, DiscordMessage message, DiscordChannel channel, List codesToLookup) + var codesToLookup = GetProductIds(args.Message.Content) + .Where(pc => !previousReplies.Contains(pc, StringComparison.InvariantCultureIgnoreCase)) + .Take(args.Channel.IsPrivate ? 50 : 5) + .ToList(); + if (codesToLookup.Count == 0) + return; + + await LookupAndPostProductCodeEmbedAsync(c, args.Message, args.Channel, codesToLookup).ConfigureAwait(false); + } + + public static async Task LookupAndPostProductCodeEmbedAsync(DiscordClient client, DiscordMessage message, DiscordChannel channel, List codesToLookup) + { + await message.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); + try { - await message.ReactWithAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - try - { - var results = new List<(string code, Task task)>(codesToLookup.Count); - foreach (var code in codesToLookup) - results.Add((code, client.LookupGameInfoAsync(code))); - var formattedResults = new List<(string code, DiscordEmbedBuilder builder)>(results.Count); - foreach (var (code, task) in results) - try - { - formattedResults.Add((code, await task.ConfigureAwait(false))); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Couldn't get product code info for {code}"); - } - - // get only results with unique titles - formattedResults = formattedResults.DistinctBy(e => e.builder.Title).ToList(); - var lookupEmoji = new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🔍")); - foreach (var result in formattedResults) - try - { - await FixAfrikaAsync(client, message, result.builder).ConfigureAwait(false); - var messageBuilder = new DiscordMessageBuilder().WithEmbed(result.builder); - if (LimitedToSpamChannel.IsSpamChannel(channel)) - messageBuilder.AddComponents(new DiscordButtonComponent(ButtonStyle.Secondary, $"replace with game updates:{message.Author.Id}:{message.Id}:{result.code}", "Check game updates instead", emoji: lookupEmoji)); - await DiscordMessageExtensions.UpdateOrCreateMessageAsync(null, channel, messageBuilder).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Couldn't post result for {result.code} ({result.builder.Title})"); - } - } - finally - { - await message.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); - } - } - - public static List GetProductIds(string? input) - { - if (string.IsNullOrEmpty(input)) - return new List(0); - - return ProductCode.Matches(input) - .Select(match => (match.Groups["letters"].Value + match.Groups["numbers"]).ToUpper()) - .Distinct() - .ToList(); - } - - public static async Task LookupGameInfoAsync(this DiscordClient client, string? code, string? gameTitle = null, bool forLog = false, string? category = null) - => (await LookupGameInfoWithEmbedAsync(client, code, gameTitle, forLog, category).ConfigureAwait(false)).embedBuilder; - - public static async Task<(DiscordEmbedBuilder embedBuilder, CompatResult? compatResult)> LookupGameInfoWithEmbedAsync(this DiscordClient client, string? code, string? gameTitle = null, bool forLog = false, string? category = null) - { - if (string.IsNullOrEmpty(code)) - return (TitleInfo.Unknown.AsEmbed(code, gameTitle, forLog), null); - - string? thumbnailUrl = null; - CompatResult? result = null; - try - { - result = await CompatClient.GetCompatResultAsync(RequestBuilder.Start().SetSearch(code), Config.Cts.Token).ConfigureAwait(false); - if (result?.ReturnCode == -2) - return (TitleInfo.Maintenance.AsEmbed(code), result); - - if (result?.ReturnCode == -1) - return (TitleInfo.CommunicationError.AsEmbed(code), result); - - thumbnailUrl = await client.GetThumbnailUrlAsync(code).ConfigureAwait(false); - - if (result?.Results != null && result.Results.TryGetValue(code, out var info)) - return (info.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); - - if (category == "1P") + var results = new List<(string code, Task task)>(codesToLookup.Count); + foreach (var code in codesToLookup) + results.Add((code, client.LookupGameInfoAsync(code))); + var formattedResults = new List<(string code, DiscordEmbedBuilder builder)>(results.Count); + foreach (var (code, task) in results) + try { - var ti = new TitleInfo - { - Commit = "8b449ce76c91d5ff7a2829b233befe7d6df4b24f", - Date = "2018-06-23", - Pr = 4802, - Status = "Playable", - }; - return (ti.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); + formattedResults.Add((code, await task.ConfigureAwait(false))); } - if (category is "2P" or "2G" or "2D" or "PP" or "PE" or "MN") + catch (Exception e) { - var ti = new TitleInfo - { - Status = "Nothing" - }; - return (ti.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); + Config.Log.Warn(e, $"Couldn't get product code info for {code}"); } - return (TitleInfo.Unknown.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Couldn't get compat result for {code}"); - return (TitleInfo.CommunicationError.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); - } - } - public static async Task FixAfrikaAsync(DiscordClient client, DiscordMessage message, DiscordEmbedBuilder titleInfoEmbed) + // get only results with unique titles + formattedResults = formattedResults.DistinctBy(e => e.builder.Title).ToList(); + var lookupEmoji = new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🔍")); + foreach (var result in formattedResults) + try + { + await FixAfrikaAsync(client, message, result.builder).ConfigureAwait(false); + var messageBuilder = new DiscordMessageBuilder().WithEmbed(result.builder); + if (LimitedToSpamChannel.IsSpamChannel(channel)) + messageBuilder.AddComponents(new DiscordButtonComponent(ButtonStyle.Secondary, $"replace with game updates:{message.Author.Id}:{message.Id}:{result.code}", "Check game updates instead", emoji: lookupEmoji)); + await DiscordMessageExtensions.UpdateOrCreateMessageAsync(null, channel, messageBuilder).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, $"Couldn't post result for {result.code} ({result.builder.Title})"); + } + } + finally { - if (message.IsOnionLike() - && ( - titleInfoEmbed.Title.Contains("africa", StringComparison.InvariantCultureIgnoreCase) - || titleInfoEmbed.Title.Contains("afrika", StringComparison.InvariantCultureIgnoreCase) - )) - { - var sqvat = client.GetEmoji(":sqvat:", Config.Reactions.No)!; - titleInfoEmbed.Title = "How about no (๑•ิཬ•ั๑)"; - if (!string.IsNullOrEmpty(titleInfoEmbed.Thumbnail?.Url)) - titleInfoEmbed.WithThumbnail("https://cdn.discordapp.com/attachments/417347469521715210/516340151589535745/onionoff.png"); - await message.ReactWithAsync(sqvat).ConfigureAwait(false); - } + await message.RemoveReactionAsync(Config.Reactions.PleaseWait).ConfigureAwait(false); } } -} + + public static List GetProductIds(string? input) + { + if (string.IsNullOrEmpty(input)) + return new List(0); + + return ProductCode.Matches(input) + .Select(match => (match.Groups["letters"].Value + match.Groups["numbers"]).ToUpper()) + .Distinct() + .ToList(); + } + + public static async Task LookupGameInfoAsync(this DiscordClient client, string? code, string? gameTitle = null, bool forLog = false, string? category = null) + => (await LookupGameInfoWithEmbedAsync(client, code, gameTitle, forLog, category).ConfigureAwait(false)).embedBuilder; + + public static async Task<(DiscordEmbedBuilder embedBuilder, CompatResult? compatResult)> LookupGameInfoWithEmbedAsync(this DiscordClient client, string? code, string? gameTitle = null, bool forLog = false, string? category = null) + { + if (string.IsNullOrEmpty(code)) + return (TitleInfo.Unknown.AsEmbed(code, gameTitle, forLog), null); + + string? thumbnailUrl = null; + CompatResult? result = null; + try + { + result = await CompatClient.GetCompatResultAsync(RequestBuilder.Start().SetSearch(code), Config.Cts.Token).ConfigureAwait(false); + if (result?.ReturnCode == -2) + return (TitleInfo.Maintenance.AsEmbed(code), result); + + if (result?.ReturnCode == -1) + return (TitleInfo.CommunicationError.AsEmbed(code), result); + + thumbnailUrl = await client.GetThumbnailUrlAsync(code).ConfigureAwait(false); + + if (result?.Results != null && result.Results.TryGetValue(code, out var info)) + return (info.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); + + if (category == "1P") + { + var ti = new TitleInfo + { + Commit = "8b449ce76c91d5ff7a2829b233befe7d6df4b24f", + Date = "2018-06-23", + Pr = 4802, + Status = "Playable", + }; + return (ti.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); + } + if (category is "2P" or "2G" or "2D" or "PP" or "PE" or "MN") + { + var ti = new TitleInfo + { + Status = "Nothing" + }; + return (ti.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); + } + return (TitleInfo.Unknown.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); + } + catch (Exception e) + { + Config.Log.Warn(e, $"Couldn't get compat result for {code}"); + return (TitleInfo.CommunicationError.AsEmbed(code, gameTitle, forLog, thumbnailUrl), result); + } + } + + public static async Task FixAfrikaAsync(DiscordClient client, DiscordMessage message, DiscordEmbedBuilder titleInfoEmbed) + { + if (message.IsOnionLike() + && ( + titleInfoEmbed.Title.Contains("africa", StringComparison.InvariantCultureIgnoreCase) + || titleInfoEmbed.Title.Contains("afrika", StringComparison.InvariantCultureIgnoreCase) + )) + { + var sqvat = client.GetEmoji(":sqvat:", Config.Reactions.No)!; + titleInfoEmbed.Title = "How about no (๑•ิཬ•ั๑)"; + if (!string.IsNullOrEmpty(titleInfoEmbed.Thumbnail?.Url)) + titleInfoEmbed.WithThumbnail("https://cdn.discordapp.com/attachments/417347469521715210/516340151589535745/onionoff.png"); + await message.ReactWithAsync(sqvat).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/Starbucks.cs b/CompatBot/EventHandlers/Starbucks.cs index 639faaea..c59156f6 100644 --- a/CompatBot/EventHandlers/Starbucks.cs +++ b/CompatBot/EventHandlers/Starbucks.cs @@ -9,228 +9,227 @@ using DSharpPlus; using DSharpPlus.Entities; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class Starbucks { - internal static class Starbucks + private static readonly Dictionary TextMap = new() { - private static readonly Dictionary TextMap = new() + [DiscordEmoji.FromUnicode("Ⓜ")] = "M", + [DiscordEmoji.FromUnicode("🅰")] = "A", + [DiscordEmoji.FromUnicode("🅱")] = "B", + [DiscordEmoji.FromUnicode("🆎")] = "AB", + [DiscordEmoji.FromUnicode("🅾")] = "O", + + [DiscordEmoji.FromUnicode("🇦")] = "A", + [DiscordEmoji.FromUnicode("🇧")] = "B", + [DiscordEmoji.FromUnicode("🇨")] = "C", + [DiscordEmoji.FromUnicode("🇩")] = "D", + [DiscordEmoji.FromUnicode("🇪")] = "E", + [DiscordEmoji.FromUnicode("🇫")] = "F", + [DiscordEmoji.FromUnicode("🇬")] = "G", + [DiscordEmoji.FromUnicode("🇭")] = "H", + [DiscordEmoji.FromUnicode("🇮")] = "I", + [DiscordEmoji.FromUnicode("🇯")] = "G", + [DiscordEmoji.FromUnicode("🇰")] = "K", + [DiscordEmoji.FromUnicode("🇱")] = "L", + [DiscordEmoji.FromUnicode("🇲")] = "M", + [DiscordEmoji.FromUnicode("🇳")] = "N", + [DiscordEmoji.FromUnicode("🇴")] = "O", + [DiscordEmoji.FromUnicode("🇵")] = "P", + [DiscordEmoji.FromUnicode("🇶")] = "Q", + [DiscordEmoji.FromUnicode("🇷")] = "R", + [DiscordEmoji.FromUnicode("🇸")] = "S", + [DiscordEmoji.FromUnicode("🇹")] = "T", + [DiscordEmoji.FromUnicode("🇺")] = "U", + [DiscordEmoji.FromUnicode("🇻")] = "V", + [DiscordEmoji.FromUnicode("🇼")] = "W", + [DiscordEmoji.FromUnicode("🇽")] = "X", + [DiscordEmoji.FromUnicode("🇾")] = "Y", + [DiscordEmoji.FromUnicode("🇿")] = "Z", + + [DiscordEmoji.FromUnicode("0\u20E3")] = "0", + [DiscordEmoji.FromUnicode("1\u20E3")] = "1", + [DiscordEmoji.FromUnicode("2\u20E3")] = "2", + [DiscordEmoji.FromUnicode("3\u20E3")] = "3", + [DiscordEmoji.FromUnicode("4\u20E3")] = "4", + [DiscordEmoji.FromUnicode("5\u20E3")] = "5", + [DiscordEmoji.FromUnicode("6\u20E3")] = "6", + [DiscordEmoji.FromUnicode("7\u20E3")] = "7", + [DiscordEmoji.FromUnicode("8\u20E3")] = "8", + [DiscordEmoji.FromUnicode("9\u20E3")] = "9", + [DiscordEmoji.FromUnicode("🔟")] = "10", + [DiscordEmoji.FromUnicode("💯")] = "100", + + [DiscordEmoji.FromUnicode("🆑")] = "CL", + [DiscordEmoji.FromUnicode("🆒")] = "COOL", + [DiscordEmoji.FromUnicode("🆓")] = "FREE", + [DiscordEmoji.FromUnicode("🆔")] = "ID", + [DiscordEmoji.FromUnicode("🆕")] = "NEW", + [DiscordEmoji.FromUnicode("🆖")] = "NG", + [DiscordEmoji.FromUnicode("🆗")] = "OK", + [DiscordEmoji.FromUnicode("🆘")] = "SOS", + [DiscordEmoji.FromUnicode("🆙")] = "UP", + [DiscordEmoji.FromUnicode("🆚")] = "VS", + [DiscordEmoji.FromUnicode("⭕")] = "O", + [DiscordEmoji.FromUnicode("🔄")] = "O", + [DiscordEmoji.FromUnicode("✝")] = "T", + [DiscordEmoji.FromUnicode("❌")] = "X", + [DiscordEmoji.FromUnicode("✖")] = "X", + [DiscordEmoji.FromUnicode("❎")] = "X", + [DiscordEmoji.FromUnicode("🅿")] = "P", + [DiscordEmoji.FromUnicode("🚾")] = "WC", + [DiscordEmoji.FromUnicode("ℹ")] = "i", + [DiscordEmoji.FromUnicode("〰")] = "W", + }; + + public static Task Handler(DiscordClient c, MessageReactionAddEventArgs args) + => CheckMessageAsync(c, args.Channel, args.User, args.Message, args.Emoji, false); + + public static async Task CheckBacklogAsync(DiscordClient client, DiscordGuild guild) + { + try { - [DiscordEmoji.FromUnicode("Ⓜ")] = "M", - [DiscordEmoji.FromUnicode("🅰")] = "A", - [DiscordEmoji.FromUnicode("🅱")] = "B", - [DiscordEmoji.FromUnicode("🆎")] = "AB", - [DiscordEmoji.FromUnicode("🅾")] = "O", - - [DiscordEmoji.FromUnicode("🇦")] = "A", - [DiscordEmoji.FromUnicode("🇧")] = "B", - [DiscordEmoji.FromUnicode("🇨")] = "C", - [DiscordEmoji.FromUnicode("🇩")] = "D", - [DiscordEmoji.FromUnicode("🇪")] = "E", - [DiscordEmoji.FromUnicode("🇫")] = "F", - [DiscordEmoji.FromUnicode("🇬")] = "G", - [DiscordEmoji.FromUnicode("🇭")] = "H", - [DiscordEmoji.FromUnicode("🇮")] = "I", - [DiscordEmoji.FromUnicode("🇯")] = "G", - [DiscordEmoji.FromUnicode("🇰")] = "K", - [DiscordEmoji.FromUnicode("🇱")] = "L", - [DiscordEmoji.FromUnicode("🇲")] = "M", - [DiscordEmoji.FromUnicode("🇳")] = "N", - [DiscordEmoji.FromUnicode("🇴")] = "O", - [DiscordEmoji.FromUnicode("🇵")] = "P", - [DiscordEmoji.FromUnicode("🇶")] = "Q", - [DiscordEmoji.FromUnicode("🇷")] = "R", - [DiscordEmoji.FromUnicode("🇸")] = "S", - [DiscordEmoji.FromUnicode("🇹")] = "T", - [DiscordEmoji.FromUnicode("🇺")] = "U", - [DiscordEmoji.FromUnicode("🇻")] = "V", - [DiscordEmoji.FromUnicode("🇼")] = "W", - [DiscordEmoji.FromUnicode("🇽")] = "X", - [DiscordEmoji.FromUnicode("🇾")] = "Y", - [DiscordEmoji.FromUnicode("🇿")] = "Z", - - [DiscordEmoji.FromUnicode("0\u20E3")] = "0", - [DiscordEmoji.FromUnicode("1\u20E3")] = "1", - [DiscordEmoji.FromUnicode("2\u20E3")] = "2", - [DiscordEmoji.FromUnicode("3\u20E3")] = "3", - [DiscordEmoji.FromUnicode("4\u20E3")] = "4", - [DiscordEmoji.FromUnicode("5\u20E3")] = "5", - [DiscordEmoji.FromUnicode("6\u20E3")] = "6", - [DiscordEmoji.FromUnicode("7\u20E3")] = "7", - [DiscordEmoji.FromUnicode("8\u20E3")] = "8", - [DiscordEmoji.FromUnicode("9\u20E3")] = "9", - [DiscordEmoji.FromUnicode("🔟")] = "10", - [DiscordEmoji.FromUnicode("💯")] = "100", - - [DiscordEmoji.FromUnicode("🆑")] = "CL", - [DiscordEmoji.FromUnicode("🆒")] = "COOL", - [DiscordEmoji.FromUnicode("🆓")] = "FREE", - [DiscordEmoji.FromUnicode("🆔")] = "ID", - [DiscordEmoji.FromUnicode("🆕")] = "NEW", - [DiscordEmoji.FromUnicode("🆖")] = "NG", - [DiscordEmoji.FromUnicode("🆗")] = "OK", - [DiscordEmoji.FromUnicode("🆘")] = "SOS", - [DiscordEmoji.FromUnicode("🆙")] = "UP", - [DiscordEmoji.FromUnicode("🆚")] = "VS", - [DiscordEmoji.FromUnicode("⭕")] = "O", - [DiscordEmoji.FromUnicode("🔄")] = "O", - [DiscordEmoji.FromUnicode("✝")] = "T", - [DiscordEmoji.FromUnicode("❌")] = "X", - [DiscordEmoji.FromUnicode("✖")] = "X", - [DiscordEmoji.FromUnicode("❎")] = "X", - [DiscordEmoji.FromUnicode("🅿")] = "P", - [DiscordEmoji.FromUnicode("🚾")] = "WC", - [DiscordEmoji.FromUnicode("ℹ")] = "i", - [DiscordEmoji.FromUnicode("〰")] = "W", - }; - - public static Task Handler(DiscordClient c, MessageReactionAddEventArgs args) - => CheckMessageAsync(c, args.Channel, args.User, args.Message, args.Emoji, false); - - public static async Task CheckBacklogAsync(DiscordClient client, DiscordGuild guild) - { - try + var after = DateTime.UtcNow - Config.ModerationBacklogThresholdInHours; + var checkTasks = new List(); + foreach (var channel in guild.Channels.Values.Where(ch => Config.Moderation.Channels.Contains(ch.Id))) { - var after = DateTime.UtcNow - Config.ModerationBacklogThresholdInHours; - var checkTasks = new List(); - foreach (var channel in guild.Channels.Values.Where(ch => Config.Moderation.Channels.Contains(ch.Id))) + var messages = await channel.GetMessagesCachedAsync().ConfigureAwait(false); + var messagesToCheck = from msg in messages + where msg.CreationTimestamp > after && msg.Reactions.Any(r => r.Emoji == Config.Reactions.Starbucks && r.Count >= Config.Moderation.StarbucksThreshold) + select msg; + foreach (var message in messagesToCheck) { - var messages = await channel.GetMessagesCachedAsync().ConfigureAwait(false); - var messagesToCheck = from msg in messages - where msg.CreationTimestamp > after && msg.Reactions.Any(r => r.Emoji == Config.Reactions.Starbucks && r.Count >= Config.Moderation.StarbucksThreshold) - select msg; - foreach (var message in messagesToCheck) - { - var reactionUsers = await message.GetReactionsAsync(Config.Reactions.Starbucks).ConfigureAwait(false); - if (reactionUsers.Count > 0) - checkTasks.Add(CheckMessageAsync(client, channel, reactionUsers[0], message, Config.Reactions.Starbucks, true)); - } + var reactionUsers = await message.GetReactionsAsync(Config.Reactions.Starbucks).ConfigureAwait(false); + if (reactionUsers.Count > 0) + checkTasks.Add(CheckMessageAsync(client, channel, reactionUsers[0], message, Config.Reactions.Starbucks, true)); } - await Task.WhenAll(checkTasks).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); } + await Task.WhenAll(checkTasks).ConfigureAwait(false); } - - private static async Task CheckMessageAsync(DiscordClient client, DiscordChannel? channel, DiscordUser user, DiscordMessage message, DiscordEmoji emoji, bool isBacklog) + catch (Exception e) { - try - { - if (user.IsBotSafeCheck() || channel is null || channel.IsPrivate) - return; - - // in case it's not in cache and doesn't contain any info, including Author - message = await channel.GetMessageAsync(message.Id).ConfigureAwait(false); - if (emoji == Config.Reactions.Starbucks) - await CheckMediaTalkAsync(client, channel, message, emoji).ConfigureAwait(false); - if (emoji == Config.Reactions.Shutup && !isBacklog) - await ShutupAsync(client, user, message).ConfigureAwait(false); - if (emoji == Config.Reactions.BadUpdate && !isBacklog) - await BadUpdateAsync(client, user, message, emoji).ConfigureAwait(false); - - await CheckGameFansAsync(client, channel, message).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e); - } - } - - private static async Task CheckMediaTalkAsync(DiscordClient client, DiscordChannel channel, DiscordMessage message, DiscordEmoji emoji) - { - if (!Config.Moderation.Channels.Contains(channel.Id)) - return; - - // message.Timestamp throws if it's not in the cache AND is in local time zone - if (DateTime.UtcNow - message.CreationTimestamp > Config.ModerationBacklogThresholdInHours) - return; - - if (message.Reactions.Any(r => r.Emoji == emoji && (r.IsMe || r.Count < Config.Moderation.StarbucksThreshold))) - return; - - if (message.Author.IsWhitelisted(client, channel.Guild)) - return; - - var users = await message.GetReactionsAsync(emoji).ConfigureAwait(false); - if (users.Any(u => u.IsCurrent)) - return; - - var members = users - .Distinct() - .Select(u => channel.Guild - .GetMemberAsync(u.Id) - .ContinueWith(ct => ct.IsCompletedSuccessfully ? ct : Task.FromResult((DiscordMember?)null), TaskScheduler.Default)) - .ToList() //force eager task creation - .Select(t => t.Unwrap().ConfigureAwait(false).GetAwaiter().GetResult()) - .Where(m => m != null) - .ToList(); - var reporters = members.Where(m => m!.Roles.Any()).ToList(); - if (reporters.Count < Config.Moderation.StarbucksThreshold) - return; - - await message.ReactWithAsync(emoji).ConfigureAwait(false); - await client.ReportAsync(Config.Reactions.Starbucks + " Media talk report", message, reporters, null, ReportSeverity.Medium).ConfigureAwait(false); - } - - - private static Task ShutupAsync(DiscordClient client, DiscordUser user, DiscordMessage message) - { - if (!message.Author.IsCurrent) - return Task.CompletedTask; - - if (message.CreationTimestamp.Add(Config.ShutupTimeLimitInMin) < DateTime.UtcNow) - return Task.CompletedTask; - - if (!user.IsWhitelisted(client, message.Channel.Guild)) - return Task.CompletedTask; - - return message.DeleteAsync(); - } - - private static async Task BadUpdateAsync(DiscordClient client, DiscordUser user, DiscordMessage message, DiscordEmoji emoji) - { - if (message.Channel.Id != Config.BotChannelId) - return; - - if (!user.IsSmartlisted(client, message.Channel.Guild)) - return; - - await Moderation.ToggleBadUpdateAnnouncementAsync(message).ConfigureAwait(false); - try - { - await message.DeleteReactionAsync(emoji, user).ConfigureAwait(false); - } - catch { } - } - - - private static async Task CheckGameFansAsync(DiscordClient client, DiscordChannel channel, DiscordMessage message) - { - var bot = client.GetMember(channel.Guild, client.CurrentUser); - var ch = channel.IsPrivate ? channel.Users.FirstOrDefault(u => u.Id != client.CurrentUser.Id)?.Username + "'s DM" : "#" + channel.Name; - if (!channel.PermissionsFor(bot).HasPermission(Permissions.AddReactions)) - { - Config.Log.Debug($"No permissions to react in {ch}"); - return; - } - - var mood = client.GetEmoji(":sqvat:", "😒"); - if (message.Reactions.Any(r => r.Emoji == mood && r.IsMe)) - return; - - var reactionMsg = string.Concat(message.Reactions.Select(r => TextMap.TryGetValue(r.Emoji, out var txt) ? txt : " ")).Trim(); - if (string.IsNullOrEmpty(reactionMsg)) - return; - - Config.Log.Debug($"Emoji text: {reactionMsg} (in {ch})"); - - if (reactionMsg.Contains("UFC")) - { - await message.CreateReactionAsync(mood).ConfigureAwait(false); - await message.CreateReactionAsync(DiscordEmoji.FromUnicode("🇳")).ConfigureAwait(false); - await message.CreateReactionAsync(DiscordEmoji.FromUnicode("🇴")).ConfigureAwait(false); - } + Config.Log.Error(e); } } -} + + private static async Task CheckMessageAsync(DiscordClient client, DiscordChannel? channel, DiscordUser user, DiscordMessage message, DiscordEmoji emoji, bool isBacklog) + { + try + { + if (user.IsBotSafeCheck() || channel is null || channel.IsPrivate) + return; + + // in case it's not in cache and doesn't contain any info, including Author + message = await channel.GetMessageAsync(message.Id).ConfigureAwait(false); + if (emoji == Config.Reactions.Starbucks) + await CheckMediaTalkAsync(client, channel, message, emoji).ConfigureAwait(false); + if (emoji == Config.Reactions.Shutup && !isBacklog) + await ShutupAsync(client, user, message).ConfigureAwait(false); + if (emoji == Config.Reactions.BadUpdate && !isBacklog) + await BadUpdateAsync(client, user, message, emoji).ConfigureAwait(false); + + await CheckGameFansAsync(client, channel, message).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e); + } + } + + private static async Task CheckMediaTalkAsync(DiscordClient client, DiscordChannel channel, DiscordMessage message, DiscordEmoji emoji) + { + if (!Config.Moderation.Channels.Contains(channel.Id)) + return; + + // message.Timestamp throws if it's not in the cache AND is in local time zone + if (DateTime.UtcNow - message.CreationTimestamp > Config.ModerationBacklogThresholdInHours) + return; + + if (message.Reactions.Any(r => r.Emoji == emoji && (r.IsMe || r.Count < Config.Moderation.StarbucksThreshold))) + return; + + if (message.Author.IsWhitelisted(client, channel.Guild)) + return; + + var users = await message.GetReactionsAsync(emoji).ConfigureAwait(false); + if (users.Any(u => u.IsCurrent)) + return; + + var members = users + .Distinct() + .Select(u => channel.Guild + .GetMemberAsync(u.Id) + .ContinueWith(ct => ct.IsCompletedSuccessfully ? ct : Task.FromResult((DiscordMember?)null), TaskScheduler.Default)) + .ToList() //force eager task creation + .Select(t => t.Unwrap().ConfigureAwait(false).GetAwaiter().GetResult()) + .Where(m => m != null) + .ToList(); + var reporters = members.Where(m => m!.Roles.Any()).ToList(); + if (reporters.Count < Config.Moderation.StarbucksThreshold) + return; + + await message.ReactWithAsync(emoji).ConfigureAwait(false); + await client.ReportAsync(Config.Reactions.Starbucks + " Media talk report", message, reporters, null, ReportSeverity.Medium).ConfigureAwait(false); + } + + + private static Task ShutupAsync(DiscordClient client, DiscordUser user, DiscordMessage message) + { + if (!message.Author.IsCurrent) + return Task.CompletedTask; + + if (message.CreationTimestamp.Add(Config.ShutupTimeLimitInMin) < DateTime.UtcNow) + return Task.CompletedTask; + + if (!user.IsWhitelisted(client, message.Channel.Guild)) + return Task.CompletedTask; + + return message.DeleteAsync(); + } + + private static async Task BadUpdateAsync(DiscordClient client, DiscordUser user, DiscordMessage message, DiscordEmoji emoji) + { + if (message.Channel.Id != Config.BotChannelId) + return; + + if (!user.IsSmartlisted(client, message.Channel.Guild)) + return; + + await Moderation.ToggleBadUpdateAnnouncementAsync(message).ConfigureAwait(false); + try + { + await message.DeleteReactionAsync(emoji, user).ConfigureAwait(false); + } + catch { } + } + + + private static async Task CheckGameFansAsync(DiscordClient client, DiscordChannel channel, DiscordMessage message) + { + var bot = client.GetMember(channel.Guild, client.CurrentUser); + var ch = channel.IsPrivate ? channel.Users.FirstOrDefault(u => u.Id != client.CurrentUser.Id)?.Username + "'s DM" : "#" + channel.Name; + if (!channel.PermissionsFor(bot).HasPermission(Permissions.AddReactions)) + { + Config.Log.Debug($"No permissions to react in {ch}"); + return; + } + + var mood = client.GetEmoji(":sqvat:", "😒"); + if (message.Reactions.Any(r => r.Emoji == mood && r.IsMe)) + return; + + var reactionMsg = string.Concat(message.Reactions.Select(r => TextMap.TryGetValue(r.Emoji, out var txt) ? txt : " ")).Trim(); + if (string.IsNullOrEmpty(reactionMsg)) + return; + + Config.Log.Debug($"Emoji text: {reactionMsg} (in {ch})"); + + if (reactionMsg.Contains("UFC")) + { + await message.CreateReactionAsync(mood).ConfigureAwait(false); + await message.CreateReactionAsync(DiscordEmoji.FromUnicode("🇳")).ConfigureAwait(false); + await message.CreateReactionAsync(DiscordEmoji.FromUnicode("🇴")).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/TableFlipMonitor.cs b/CompatBot/EventHandlers/TableFlipMonitor.cs index e3a8e8fb..9328a80d 100644 --- a/CompatBot/EventHandlers/TableFlipMonitor.cs +++ b/CompatBot/EventHandlers/TableFlipMonitor.cs @@ -10,88 +10,87 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.EventHandlers -{ - internal static class TableFlipMonitor - { - private static readonly char[] OpenParen = {'(', '(', 'ʕ'}; +namespace CompatBot.EventHandlers; - public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) +internal static class TableFlipMonitor +{ + private static readonly char[] OpenParen = {'(', '(', 'ʕ'}; + + public static async Task OnMessageCreated(DiscordClient _, MessageCreateEventArgs args) + { + if (DefaultHandlerFilter.IsFluff(args.Message)) + return; + + /* + * (╯°□°)╯︵ ┻━┻ + * (ノ ゜Д゜)ノ ︵ ┻━┻ + * (ノಠ益ಠ)ノ彡┻━┻ + * ‎(ノಥ益ಥ)ノ ┻━┻ + * (ノಥДಥ)ノ︵┻━┻・/ + * (ノ^_^)ノ┻━┻ + * (/¯◡ ‿ ◡)/¯ ~ ┻━┻ + * + * this might look the same, but only because of the font choice + * + * ┻━┻ + * ┻━┻ + */ + try { - if (DefaultHandlerFilter.IsFluff(args.Message)) + var content = args.Message.Content; + + if (content.Contains("🎲") && Regex.IsMatch(content, @"(🎲|\s)+")) + { + var count = 1; + var idx = content.IndexOf("🎲"); + while (idx < content.Length && (idx = content.IndexOf("🎲", idx + 1)) > 0) + count++; + EmpathySimulationHandler.Throttling.Set(args.Channel.Id, new List {args.Message}, EmpathySimulationHandler.ThrottleDuration); + await Misc.RollImpl(args.Message, $"{count}d6").ConfigureAwait(false); + return; + } + + if (content.Trim() == "🥠") + { + EmpathySimulationHandler.Throttling.Set(args.Channel.Id, new List {args.Message}, EmpathySimulationHandler.ThrottleDuration); + await Fortune.ShowFortune(args.Message, args.Author).ConfigureAwait(false); + return; + } + + if (!(content.Contains("┻━┻") || + content.Contains("┻━┻"))) return; - /* - * (╯°□°)╯︵ ┻━┻ - * (ノ ゜Д゜)ノ ︵ ┻━┻ - * (ノಠ益ಠ)ノ彡┻━┻ - * ‎(ノಥ益ಥ)ノ ┻━┻ - * (ノಥДಥ)ノ︵┻━┻・/ - * (ノ^_^)ノ┻━┻ - * (/¯◡ ‿ ◡)/¯ ~ ┻━┻ - * - * this might look the same, but only because of the font choice - * - * ┻━┻ - * ┻━┻ - */ - try - { - var content = args.Message.Content; + var tableIdx = content.IndexOf("┻━┻", StringComparison.Ordinal); + if (tableIdx < 0) + tableIdx = content.IndexOf("┻━┻", StringComparison.Ordinal); + var faceIdx = content[..tableIdx].LastIndexOfAny(OpenParen); + var face = content[faceIdx..tableIdx]; + if (face.Length > 30) + return; - if (content.Contains("🎲") && Regex.IsMatch(content, @"(🎲|\s)+")) - { - var count = 1; - var idx = content.IndexOf("🎲"); - while (idx < content.Length && (idx = content.IndexOf("🎲", idx + 1)) > 0) - count++; - EmpathySimulationHandler.Throttling.Set(args.Channel.Id, new List {args.Message}, EmpathySimulationHandler.ThrottleDuration); - await Misc.RollImpl(args.Message, $"{count}d6").ConfigureAwait(false); - return; - } - - if (content.Trim() == "🥠") - { - EmpathySimulationHandler.Throttling.Set(args.Channel.Id, new List {args.Message}, EmpathySimulationHandler.ThrottleDuration); - await Fortune.ShowFortune(args.Message, args.Author).ConfigureAwait(false); - return; - } + var reverseFace = face + .Replace("(╯", "╯(").Replace("(ノ", "ノ(").Replace("(ノ", "ノ(").Replace("(/¯", @"\_/(") + .Replace(")╯", "╯)").Replace(")ノ", "ノ)").Replace(")ノ", "ノ)").Replace(")/¯", @"\_/)") - if (!(content.Contains("┻━┻") || - content.Contains("┻━┻"))) - return; + .Replace("(╯", "╯(").Replace("(ノ", "ノ(").Replace("(ノ", "ノ(").Replace("(/¯", @"\_/(") + .Replace(")╯", "╯)").Replace(")ノ", "ノ)").Replace(")ノ", "ノ)").Replace(")/¯", @"\_/)") - var tableIdx = content.IndexOf("┻━┻", StringComparison.Ordinal); - if (tableIdx < 0) - tableIdx = content.IndexOf("┻━┻", StringComparison.Ordinal); - var faceIdx = content[..tableIdx].LastIndexOfAny(OpenParen); - var face = content[faceIdx..tableIdx]; - if (face.Length > 30) - return; + .Replace("ʕ╯", "╯ʕ").Replace("ʕノ", "ノʕ").Replace("ʕノ", "ノʕ").Replace("ʕ/¯", @"\_/ʕ") + .Replace("ʔ╯", "╯ʔ").Replace("ʔノ", "ノʔ").Replace("ʔノ", "ノʔ").Replace("ʔ/¯", @"\_/ʔ") - var reverseFace = face - .Replace("(╯", "╯(").Replace("(ノ", "ノ(").Replace("(ノ", "ノ(").Replace("(/¯", @"\_/(") - .Replace(")╯", "╯)").Replace(")ノ", "ノ)").Replace(")ノ", "ノ)").Replace(")/¯", @"\_/)") + .TrimEnd('︵', '彡', ' ', ' ', '~', '~'); + if (reverseFace == face) + return; - .Replace("(╯", "╯(").Replace("(ノ", "ノ(").Replace("(ノ", "ノ(").Replace("(/¯", @"\_/(") - .Replace(")╯", "╯)").Replace(")ノ", "ノ)").Replace(")ノ", "ノ)").Replace(")/¯", @"\_/)") - - .Replace("ʕ╯", "╯ʕ").Replace("ʕノ", "ノʕ").Replace("ʕノ", "ノʕ").Replace("ʕ/¯", @"\_/ʕ") - .Replace("ʔ╯", "╯ʔ").Replace("ʔノ", "ノʔ").Replace("ʔノ", "ノʔ").Replace("ʔ/¯", @"\_/ʔ") - - .TrimEnd('︵', '彡', ' ', ' ', '~', '~'); - if (reverseFace == face) - return; - - var faceLength = reverseFace.Length; - if (faceLength > 5 + 4) - reverseFace = $"{reverseFace[..2]}ಠ益ಠ{reverseFace[^2..]}"; - await args.Channel.SendMessageAsync("┬─┬ " + reverseFace.Sanitize()).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e); - } + var faceLength = reverseFace.Length; + if (faceLength > 5 + 4) + reverseFace = $"{reverseFace[..2]}ಠ益ಠ{reverseFace[^2..]}"; + await args.Channel.SendMessageAsync("┬─┬ " + reverseFace.Sanitize()).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/ThumbnailCacheMonitor.cs b/CompatBot/EventHandlers/ThumbnailCacheMonitor.cs index 32f6177e..e24648f2 100644 --- a/CompatBot/EventHandlers/ThumbnailCacheMonitor.cs +++ b/CompatBot/EventHandlers/ThumbnailCacheMonitor.cs @@ -4,28 +4,27 @@ using CompatBot.Database; using DSharpPlus; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class ThumbnailCacheMonitor { - internal static class ThumbnailCacheMonitor + public static async Task OnMessageDeleted(DiscordClient _, MessageDeleteEventArgs args) { - public static async Task OnMessageDeleted(DiscordClient _, MessageDeleteEventArgs args) + if (args.Channel.Id != Config.ThumbnailSpamId) + return; + + if (string.IsNullOrEmpty(args.Message.Content)) + return; + + if (!args.Message.Attachments.Any()) + return; + + await using var db = new ThumbnailDb(); + var thumb = db.Thumbnail.FirstOrDefault(i => i.ContentId == args.Message.Content); + if (thumb?.EmbeddableUrl is string url && !string.IsNullOrEmpty(url) && args.Message.Attachments.Any(a => a.Url == url)) { - if (args.Channel.Id != Config.ThumbnailSpamId) - return; - - if (string.IsNullOrEmpty(args.Message.Content)) - return; - - if (!args.Message.Attachments.Any()) - return; - - await using var db = new ThumbnailDb(); - var thumb = db.Thumbnail.FirstOrDefault(i => i.ContentId == args.Message.Content); - if (thumb?.EmbeddableUrl is string url && !string.IsNullOrEmpty(url) && args.Message.Attachments.Any(a => a.Url == url)) - { - thumb.EmbeddableUrl = null; - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - } + thumb.EmbeddableUrl = null; + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/UnknownCommandHandler.cs b/CompatBot/EventHandlers/UnknownCommandHandler.cs index 707da8c6..62c63d01 100644 --- a/CompatBot/EventHandlers/UnknownCommandHandler.cs +++ b/CompatBot/EventHandlers/UnknownCommandHandler.cs @@ -10,162 +10,161 @@ using DSharpPlus.CommandsNext.Exceptions; using DSharpPlus.Entities; using DSharpPlus.Interactivity.Extensions; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class UnknownCommandHandler { - internal static class UnknownCommandHandler + public static Task OnError(CommandsNextExtension cne, CommandErrorEventArgs e) { - public static Task OnError(CommandsNextExtension cne, CommandErrorEventArgs e) - { - OnErrorInternal(cne, e); - return Task.CompletedTask; - } - - public static async void OnErrorInternal(CommandsNextExtension cne, CommandErrorEventArgs e) - { - try - { - if (e.Context.User.IsBotSafeCheck()) - return; - - var ex = e.Exception; - if (ex is InvalidOperationException && ex.Message.Contains("No matching subcommands were found")) - ex = new CommandNotFoundException(e.Command?.Name ?? "unknown command name"); - - if (ex is not CommandNotFoundException cnfe) - { - Config.Log.Error(e.Exception); - return; - } - - if (string.IsNullOrEmpty(cnfe.CommandName)) - return; - - if (e.Context.Prefix != Config.CommandPrefix - && e.Context.Prefix != Config.AutoRemoveCommandPrefix - && (e.Context.Message.Content?.EndsWith("?") ?? false) - && e.Context.CommandsNext.RegisteredCommands.TryGetValue("8ball", out var cmd)) - { - var updatedContext = e.Context.CommandsNext.CreateContext( - e.Context.Message, - e.Context.Prefix, - cmd, - e.Context.Message.Content[e.Context.Prefix.Length ..].Trim() - ); - try { await cmd.ExecuteAsync(updatedContext).ConfigureAwait(false); } catch { } - return; - } - - var content = e.Context.Message.Content; - if (content is null or {Length: <3}) - return; - - if (e.Context.Prefix == Config.CommandPrefix) - { - var knownCmds = GetAllRegisteredCommands(e.Context); - var termParts = content.Split(' ', 4, StringSplitOptions.RemoveEmptyEntries); - var normalizedTerm = string.Join(' ', termParts); - var terms = new string[termParts.Length]; - terms[0] = termParts[0].ToLowerInvariant(); - for (var i = 1; i < termParts.Length; i++) - terms[i] = terms[i - 1] + ' ' + termParts[i].ToLowerInvariant(); - var cmdMatches = ( - from t in terms - from kc in knownCmds - let v = (cmd: kc.alias, fqn: kc.fqn, w: t.GetFuzzyCoefficientCached(kc.alias), arg: normalizedTerm[t.Length ..]) - where v.w is >0.5 and <1 // if it was a 100% match, we wouldn't be here - orderby v.w descending - select v - ) - .DistinctBy(i => i.fqn) - .Take(4) - .ToList(); - var btnExplain = new DiscordButtonComponent(cmdMatches.Count == 0 ? ButtonStyle.Primary : ButtonStyle.Secondary, "unk:cmd:explain", "Explain this", emoji: new(DiscordEmoji.FromUnicode("🔍"))); - var btnCompat = new DiscordButtonComponent(ButtonStyle.Secondary, "unk:cmd:compat", "Is this game playable?", emoji: new(DiscordEmoji.FromUnicode("🔍"))); - var btnHelp = new DiscordButtonComponent(ButtonStyle.Secondary, "unk:cmd:help", "Show bot commands", emoji: new(DiscordEmoji.FromUnicode("❔"))); - var btnCancel = new DiscordButtonComponent(ButtonStyle.Danger, "unk:cmd:cancel", "Ignore", emoji: new(DiscordEmoji.FromUnicode("✖"))); - var cmdEmoji = new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🤖")); - var msgBuilder = new DiscordMessageBuilder() - .WithContent("I'm afraid the intended command didn't spell out quite right") - .AddComponents(btnExplain, btnCompat, btnHelp, btnCancel); - if (cmdMatches.Count > 0) - { - var btnSuggest = cmdMatches.Select((m, i) => new DiscordButtonComponent(i == 0 ? ButtonStyle.Primary : ButtonStyle.Secondary, "unk:cmd:s:" + m.cmd, Config.CommandPrefix + m.fqn + m.arg, emoji: cmdEmoji)); - foreach (var btn in btnSuggest) - msgBuilder.AddComponents(btn); - } - var interactivity = cne.Client.GetInteractivity(); - var botMsg = await DiscordMessageExtensions.UpdateOrCreateMessageAsync(null, e.Context.Channel, msgBuilder).ConfigureAwait(false); - var (_, reaction) = await interactivity.WaitForMessageOrButtonAsync(botMsg, e.Context.User, TimeSpan.FromMinutes(1)).ConfigureAwait(false); - string? newCmd = null, newArg = content; - if (reaction?.Id is string btnId) - { - if (btnId == btnCompat.CustomId) - newCmd = "c"; - else if (btnId == btnExplain.CustomId) - newCmd = "explain"; - else if (btnId == btnHelp.CustomId) - { - newCmd = "help"; - newArg = null; - } - else if (btnId.StartsWith("unk:cmd:s:")) - { - newCmd = btnId["unk:cmd:s:".Length ..]; - newArg = cmdMatches.First(m => m.cmd == newCmd).arg; - } - } - try { await botMsg.DeleteAsync().ConfigureAwait(false); } catch { } - if (newCmd is not null) - { - var botCommand = cne.FindCommand(newCmd, out _); - var commandCtx = cne.CreateContext(e.Context.Message, e.Context.Prefix, botCommand, newArg); - await cne.ExecuteCommandAsync(commandCtx).ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - Config.Log.Error(ex); - } - } - - private static List<(string alias, string fqn)> GetAllRegisteredCommands(CommandContext ctx) - { - if (allKnownBotCommands != null) - return allKnownBotCommands; - - static void dumpCmd(List<(string alias, string fqn)> commandList, Command cmd, string qualifiedPrefix) - { - foreach (var alias in cmd.Aliases.Concat(new[] {cmd.Name})) - { - var qualifiedAlias = qualifiedPrefix + alias; - if (cmd is CommandGroup g) - { - if (g.IsExecutableWithoutSubcommands) - commandList.Add((qualifiedAlias, cmd.QualifiedName)); - dumpChildren(g, commandList, qualifiedAlias + " "); - } - else - commandList.Add((qualifiedAlias, cmd.QualifiedName)); - } - } - - static void dumpChildren(CommandGroup group, List<(string alias, string fqn)> commandList, string qualifiedPrefix) - { - foreach (var cmd in group.Children) - dumpCmd(commandList, cmd, qualifiedPrefix); - } - - var result = new List<(string alias, string fqn)>(); - foreach (var cmd in ctx.CommandsNext.RegisteredCommands.Values) - dumpCmd(result, cmd, ""); - allKnownBotCommands = result; -#if DEBUG - Config.Log.Debug("Total command alias permutations: " + allKnownBotCommands.Count); -#endif - return allKnownBotCommands; - } - - private static List<(string alias, string fqn)>? allKnownBotCommands; + OnErrorInternal(cne, e); + return Task.CompletedTask; } -} + + public static async void OnErrorInternal(CommandsNextExtension cne, CommandErrorEventArgs e) + { + try + { + if (e.Context.User.IsBotSafeCheck()) + return; + + var ex = e.Exception; + if (ex is InvalidOperationException && ex.Message.Contains("No matching subcommands were found")) + ex = new CommandNotFoundException(e.Command?.Name ?? "unknown command name"); + + if (ex is not CommandNotFoundException cnfe) + { + Config.Log.Error(e.Exception); + return; + } + + if (string.IsNullOrEmpty(cnfe.CommandName)) + return; + + if (e.Context.Prefix != Config.CommandPrefix + && e.Context.Prefix != Config.AutoRemoveCommandPrefix + && (e.Context.Message.Content?.EndsWith("?") ?? false) + && e.Context.CommandsNext.RegisteredCommands.TryGetValue("8ball", out var cmd)) + { + var updatedContext = e.Context.CommandsNext.CreateContext( + e.Context.Message, + e.Context.Prefix, + cmd, + e.Context.Message.Content[e.Context.Prefix.Length ..].Trim() + ); + try { await cmd.ExecuteAsync(updatedContext).ConfigureAwait(false); } catch { } + return; + } + + var content = e.Context.Message.Content; + if (content is null or {Length: <3}) + return; + + if (e.Context.Prefix == Config.CommandPrefix) + { + var knownCmds = GetAllRegisteredCommands(e.Context); + var termParts = content.Split(' ', 4, StringSplitOptions.RemoveEmptyEntries); + var normalizedTerm = string.Join(' ', termParts); + var terms = new string[termParts.Length]; + terms[0] = termParts[0].ToLowerInvariant(); + for (var i = 1; i < termParts.Length; i++) + terms[i] = terms[i - 1] + ' ' + termParts[i].ToLowerInvariant(); + var cmdMatches = ( + from t in terms + from kc in knownCmds + let v = (cmd: kc.alias, fqn: kc.fqn, w: t.GetFuzzyCoefficientCached(kc.alias), arg: normalizedTerm[t.Length ..]) + where v.w is >0.5 and <1 // if it was a 100% match, we wouldn't be here + orderby v.w descending + select v + ) + .DistinctBy(i => i.fqn) + .Take(4) + .ToList(); + var btnExplain = new DiscordButtonComponent(cmdMatches.Count == 0 ? ButtonStyle.Primary : ButtonStyle.Secondary, "unk:cmd:explain", "Explain this", emoji: new(DiscordEmoji.FromUnicode("🔍"))); + var btnCompat = new DiscordButtonComponent(ButtonStyle.Secondary, "unk:cmd:compat", "Is this game playable?", emoji: new(DiscordEmoji.FromUnicode("🔍"))); + var btnHelp = new DiscordButtonComponent(ButtonStyle.Secondary, "unk:cmd:help", "Show bot commands", emoji: new(DiscordEmoji.FromUnicode("❔"))); + var btnCancel = new DiscordButtonComponent(ButtonStyle.Danger, "unk:cmd:cancel", "Ignore", emoji: new(DiscordEmoji.FromUnicode("✖"))); + var cmdEmoji = new DiscordComponentEmoji(DiscordEmoji.FromUnicode("🤖")); + var msgBuilder = new DiscordMessageBuilder() + .WithContent("I'm afraid the intended command didn't spell out quite right") + .AddComponents(btnExplain, btnCompat, btnHelp, btnCancel); + if (cmdMatches.Count > 0) + { + var btnSuggest = cmdMatches.Select((m, i) => new DiscordButtonComponent(i == 0 ? ButtonStyle.Primary : ButtonStyle.Secondary, "unk:cmd:s:" + m.cmd, Config.CommandPrefix + m.fqn + m.arg, emoji: cmdEmoji)); + foreach (var btn in btnSuggest) + msgBuilder.AddComponents(btn); + } + var interactivity = cne.Client.GetInteractivity(); + var botMsg = await DiscordMessageExtensions.UpdateOrCreateMessageAsync(null, e.Context.Channel, msgBuilder).ConfigureAwait(false); + var (_, reaction) = await interactivity.WaitForMessageOrButtonAsync(botMsg, e.Context.User, TimeSpan.FromMinutes(1)).ConfigureAwait(false); + string? newCmd = null, newArg = content; + if (reaction?.Id is string btnId) + { + if (btnId == btnCompat.CustomId) + newCmd = "c"; + else if (btnId == btnExplain.CustomId) + newCmd = "explain"; + else if (btnId == btnHelp.CustomId) + { + newCmd = "help"; + newArg = null; + } + else if (btnId.StartsWith("unk:cmd:s:")) + { + newCmd = btnId["unk:cmd:s:".Length ..]; + newArg = cmdMatches.First(m => m.cmd == newCmd).arg; + } + } + try { await botMsg.DeleteAsync().ConfigureAwait(false); } catch { } + if (newCmd is not null) + { + var botCommand = cne.FindCommand(newCmd, out _); + var commandCtx = cne.CreateContext(e.Context.Message, e.Context.Prefix, botCommand, newArg); + await cne.ExecuteCommandAsync(commandCtx).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + Config.Log.Error(ex); + } + } + + private static List<(string alias, string fqn)> GetAllRegisteredCommands(CommandContext ctx) + { + if (allKnownBotCommands != null) + return allKnownBotCommands; + + static void dumpCmd(List<(string alias, string fqn)> commandList, Command cmd, string qualifiedPrefix) + { + foreach (var alias in cmd.Aliases.Concat(new[] {cmd.Name})) + { + var qualifiedAlias = qualifiedPrefix + alias; + if (cmd is CommandGroup g) + { + if (g.IsExecutableWithoutSubcommands) + commandList.Add((qualifiedAlias, cmd.QualifiedName)); + dumpChildren(g, commandList, qualifiedAlias + " "); + } + else + commandList.Add((qualifiedAlias, cmd.QualifiedName)); + } + } + + static void dumpChildren(CommandGroup group, List<(string alias, string fqn)> commandList, string qualifiedPrefix) + { + foreach (var cmd in group.Children) + dumpCmd(commandList, cmd, qualifiedPrefix); + } + + var result = new List<(string alias, string fqn)>(); + foreach (var cmd in ctx.CommandsNext.RegisteredCommands.Values) + dumpCmd(result, cmd, ""); + allKnownBotCommands = result; +#if DEBUG + Config.Log.Debug("Total command alias permutations: " + allKnownBotCommands.Count); +#endif + return allKnownBotCommands; + } + + private static List<(string alias, string fqn)>? allKnownBotCommands; +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/UsernameRaidMonitor.cs b/CompatBot/EventHandlers/UsernameRaidMonitor.cs index 91f43d8e..5b1d77e3 100644 --- a/CompatBot/EventHandlers/UsernameRaidMonitor.cs +++ b/CompatBot/EventHandlers/UsernameRaidMonitor.cs @@ -4,57 +4,56 @@ using CompatBot.Utils; using DSharpPlus; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +public static class UsernameRaidMonitor { - public static class UsernameRaidMonitor + public static async Task OnMemberUpdated(DiscordClient c, GuildMemberUpdateEventArgs args) { - public static async Task OnMemberUpdated(DiscordClient c, GuildMemberUpdateEventArgs args) + try { - try + //member object most likely will not be updated in client cache at this moment + string? fallback; + if (args.NicknameAfter is string name) + fallback = args.Member.Username; + else { - //member object most likely will not be updated in client cache at this moment - string? fallback; - if (args.NicknameAfter is string name) - fallback = args.Member.Username; - else - { - name = args.Member.Username; - fallback = null; - } - - var member = await args.Guild.GetMemberAsync(args.Member.Id).ConfigureAwait(false) ?? args.Member; - if (NeedsKick(name)) - { - await args.Member.RemoveAsync("Anti Raid").ConfigureAwait(false); - } + name = args.Member.Username; + fallback = null; } - catch (Exception e) + + var member = await args.Guild.GetMemberAsync(args.Member.Id).ConfigureAwait(false) ?? args.Member; + if (NeedsKick(name)) { - Config.Log.Error(e); + await args.Member.RemoveAsync("Anti Raid").ConfigureAwait(false); } } - - public static async Task OnMemberAdded(DiscordClient c, GuildMemberAddEventArgs args) + catch (Exception e) { - try - { - var name = args.Member.DisplayName; - if (NeedsKick(name)) - { - await args.Member.RemoveAsync("Anti Raid").ConfigureAwait(false); - } - } - catch (Exception e) - { - Config.Log.Error(e); - } + Config.Log.Error(e); } - - public static bool NeedsKick(string displayName) - { - displayName = displayName.Normalize().TrimEager(); - return displayName.Equals("D𝗂scord"); - } - } -} + + public static async Task OnMemberAdded(DiscordClient c, GuildMemberAddEventArgs args) + { + try + { + var name = args.Member.DisplayName; + if (NeedsKick(name)) + { + await args.Member.RemoveAsync("Anti Raid").ConfigureAwait(false); + } + } + catch (Exception e) + { + Config.Log.Error(e); + } + } + + public static bool NeedsKick(string displayName) + { + displayName = displayName.Normalize().TrimEager(); + return displayName.Equals("D𝗂scord"); + } + +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/UsernameSpoofMonitor.cs b/CompatBot/EventHandlers/UsernameSpoofMonitor.cs index 58366dbb..9a4fa10f 100644 --- a/CompatBot/EventHandlers/UsernameSpoofMonitor.cs +++ b/CompatBot/EventHandlers/UsernameSpoofMonitor.cs @@ -11,145 +11,144 @@ using DSharpPlus.EventArgs; using HomoglyphConverter; using Microsoft.Extensions.Caching.Memory; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +internal static class UsernameSpoofMonitor { - internal static class UsernameSpoofMonitor + private static readonly Dictionary UsernameMapping = new(); + private static readonly SemaphoreSlim UsernameLock = new(1, 1); + private static readonly MemoryCache SpoofingReportThrottlingCache = new(new MemoryCacheOptions{ ExpirationScanFrequency = TimeSpan.FromMinutes(10) }); + private static readonly TimeSpan SnoozeDuration = TimeSpan.FromHours(1); + + public static async Task OnUserUpdated(DiscordClient c, UserUpdateEventArgs args) { - private static readonly Dictionary UsernameMapping = new(); - private static readonly SemaphoreSlim UsernameLock = new(1, 1); - private static readonly MemoryCache SpoofingReportThrottlingCache = new(new MemoryCacheOptions{ ExpirationScanFrequency = TimeSpan.FromMinutes(10) }); - private static readonly TimeSpan SnoozeDuration = TimeSpan.FromHours(1); + if (args.UserBefore.Username == args.UserAfter.Username) + return; - public static async Task OnUserUpdated(DiscordClient c, UserUpdateEventArgs args) - { - if (args.UserBefore.Username == args.UserAfter.Username) - return; - - var m = c.GetMember(args.UserAfter); - if (m is null) - return; + var m = c.GetMember(args.UserAfter); + if (m is null) + return; - var potentialTargets = GetPotentialVictims(c, m, true, false); - if (!potentialTargets.Any()) - return; + var potentialTargets = GetPotentialVictims(c, m, true, false); + if (!potentialTargets.Any()) + return; - if (await IsFlashMobAsync(c, potentialTargets).ConfigureAwait(false)) - return; + if (await IsFlashMobAsync(c, potentialTargets).ConfigureAwait(false)) + return; - await c.ReportAsync("🕵️ Potential user impersonation", - $"User {m.GetMentionWithNickname()} has changed their __username__ from " + - $"**{args.UserBefore.Username.Sanitize()}#{args.UserBefore.Discriminator}** to " + - $"**{args.UserAfter.Username.Sanitize()}#{args.UserAfter.Discriminator}**", - potentialTargets, - ReportSeverity.Medium); - } + await c.ReportAsync("🕵️ Potential user impersonation", + $"User {m.GetMentionWithNickname()} has changed their __username__ from " + + $"**{args.UserBefore.Username.Sanitize()}#{args.UserBefore.Discriminator}** to " + + $"**{args.UserAfter.Username.Sanitize()}#{args.UserAfter.Discriminator}**", + potentialTargets, + ReportSeverity.Medium); + } - public static async Task OnMemberUpdated(DiscordClient c, GuildMemberUpdateEventArgs args) - { - if (args.NicknameBefore == args.NicknameAfter) - return; + public static async Task OnMemberUpdated(DiscordClient c, GuildMemberUpdateEventArgs args) + { + if (args.NicknameBefore == args.NicknameAfter) + return; - var potentialTargets = GetPotentialVictims(c, args.Member, false, true); - if (!potentialTargets.Any()) - return; + var potentialTargets = GetPotentialVictims(c, args.Member, false, true); + if (!potentialTargets.Any()) + return; - if (await IsFlashMobAsync(c, potentialTargets).ConfigureAwait(false)) - return; + if (await IsFlashMobAsync(c, potentialTargets).ConfigureAwait(false)) + return; - await c.ReportAsync("🕵️ Potential user impersonation", - $"Member {args.Member.GetMentionWithNickname()} has changed their __display name__ from " + - $"**{(args.NicknameBefore ?? args.Member.Username).Sanitize()}** to " + - $"**{args.Member.DisplayName.Sanitize()}**", - potentialTargets, - ReportSeverity.Medium); - } + await c.ReportAsync("🕵️ Potential user impersonation", + $"Member {args.Member.GetMentionWithNickname()} has changed their __display name__ from " + + $"**{(args.NicknameBefore ?? args.Member.Username).Sanitize()}** to " + + $"**{args.Member.DisplayName.Sanitize()}**", + potentialTargets, + ReportSeverity.Medium); + } - public static async Task OnMemberAdded(DiscordClient c, GuildMemberAddEventArgs args) - { - var potentialTargets = GetPotentialVictims(c, args.Member, true, true); - if (!potentialTargets.Any()) - return; + public static async Task OnMemberAdded(DiscordClient c, GuildMemberAddEventArgs args) + { + var potentialTargets = GetPotentialVictims(c, args.Member, true, true); + if (!potentialTargets.Any()) + return; - if (await IsFlashMobAsync(c, potentialTargets).ConfigureAwait(false)) - return; + if (await IsFlashMobAsync(c, potentialTargets).ConfigureAwait(false)) + return; - await c.ReportAsync("🕵️ Potential user impersonation", - $"New member joined the server: {args.Member.GetMentionWithNickname()}", - potentialTargets, - ReportSeverity.Medium); - } + await c.ReportAsync("🕵️ Potential user impersonation", + $"New member joined the server: {args.Member.GetMentionWithNickname()}", + potentialTargets, + ReportSeverity.Medium); + } - internal static List GetPotentialVictims(DiscordClient client, DiscordMember newMember, bool checkUsername, bool checkNickname, List? listToCheckAgainst = null) - { - var membersWithRoles = listToCheckAgainst ?? - client.Guilds.SelectMany(guild => guild.Value.Members.Values) - .Where(m => m.Roles.Any() || m.IsCurrent) - .OrderByDescending(m => m.Hierarchy) - .ThenByDescending(m => m.JoinedAt) - .ToList(); - var newUsername = GetCanonical(newMember.Username); - var newDisplayName = GetCanonical(newMember.DisplayName); - var potentialTargets = new List(); - foreach (var memberWithRole in membersWithRoles) - if (checkUsername && newUsername == GetCanonical(memberWithRole.Username) && newMember.Id != memberWithRole.Id) - potentialTargets.Add(memberWithRole); - else if (checkNickname && (newDisplayName == GetCanonical(memberWithRole.DisplayName) || newDisplayName == GetCanonical(memberWithRole.Username)) && newMember.Id != memberWithRole.Id) - potentialTargets.Add(memberWithRole); - return potentialTargets; - } + internal static List GetPotentialVictims(DiscordClient client, DiscordMember newMember, bool checkUsername, bool checkNickname, List? listToCheckAgainst = null) + { + var membersWithRoles = listToCheckAgainst ?? + client.Guilds.SelectMany(guild => guild.Value.Members.Values) + .Where(m => m.Roles.Any() || m.IsCurrent) + .OrderByDescending(m => m.Hierarchy) + .ThenByDescending(m => m.JoinedAt) + .ToList(); + var newUsername = GetCanonical(newMember.Username); + var newDisplayName = GetCanonical(newMember.DisplayName); + var potentialTargets = new List(); + foreach (var memberWithRole in membersWithRoles) + if (checkUsername && newUsername == GetCanonical(memberWithRole.Username) && newMember.Id != memberWithRole.Id) + potentialTargets.Add(memberWithRole); + else if (checkNickname && (newDisplayName == GetCanonical(memberWithRole.DisplayName) || newDisplayName == GetCanonical(memberWithRole.Username)) && newMember.Id != memberWithRole.Id) + potentialTargets.Add(memberWithRole); + return potentialTargets; + } - private static async Task IsFlashMobAsync(DiscordClient client, List potentialVictims) - { - if (potentialVictims.Count == 0) - return false; + private static async Task IsFlashMobAsync(DiscordClient client, List potentialVictims) + { + if (potentialVictims.Count == 0) + return false; + try + { + var displayName = GetCanonical(potentialVictims[0].DisplayName); + if (SpoofingReportThrottlingCache.TryGetValue(displayName, out string s) && !string.IsNullOrEmpty(s)) + { + SpoofingReportThrottlingCache.Set(displayName, s, SnoozeDuration); + return true; + } + + if (potentialVictims.Count > 3) + { + SpoofingReportThrottlingCache.Set(displayName, "y", SnoozeDuration); + var channel = await client.GetChannelAsync(Config.BotLogId).ConfigureAwait(false); + await channel.SendMessageAsync($"`{displayName.Sanitize()}` is a popular member! Snoozing notifications for an hour").ConfigureAwait(false); + return true; + } + } + catch (Exception e) + { + Config.Log.Debug(e); + } + return false; + } + + private static string GetCanonical(string name) + { + if (UsernameLock.Wait(0)) try { - var displayName = GetCanonical(potentialVictims[0].DisplayName); - if (SpoofingReportThrottlingCache.TryGetValue(displayName, out string s) && !string.IsNullOrEmpty(s)) - { - SpoofingReportThrottlingCache.Set(displayName, s, SnoozeDuration); - return true; - } - - if (potentialVictims.Count > 3) - { - SpoofingReportThrottlingCache.Set(displayName, "y", SnoozeDuration); - var channel = await client.GetChannelAsync(Config.BotLogId).ConfigureAwait(false); - await channel.SendMessageAsync($"`{displayName.Sanitize()}` is a popular member! Snoozing notifications for an hour").ConfigureAwait(false); - return true; - } + if (UsernameMapping.TryGetValue(name, out var result)) + return result; } - catch (Exception e) + finally { - Config.Log.Debug(e); + UsernameLock.Release(); } - return false; - } - - private static string GetCanonical(string name) - { - if (UsernameLock.Wait(0)) - try - { - if (UsernameMapping.TryGetValue(name, out var result)) - return result; - } - finally - { - UsernameLock.Release(); - } - var canonicalName = name.ToCanonicalForm(); - if (UsernameLock.Wait(0)) - try - { - UsernameMapping[name] = canonicalName; - } - finally - { - UsernameLock.Release(); - } - return canonicalName; - } + var canonicalName = name.ToCanonicalForm(); + if (UsernameLock.Wait(0)) + try + { + UsernameMapping[name] = canonicalName; + } + finally + { + UsernameLock.Release(); + } + return canonicalName; } -} +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/UsernameValidationMonitor.cs b/CompatBot/EventHandlers/UsernameValidationMonitor.cs index cb4fc80d..0f951bb2 100644 --- a/CompatBot/EventHandlers/UsernameValidationMonitor.cs +++ b/CompatBot/EventHandlers/UsernameValidationMonitor.cs @@ -9,90 +9,89 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using Microsoft.EntityFrameworkCore; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +public static class UsernameValidationMonitor { - public static class UsernameValidationMonitor + public static Task OnMemberUpdated(DiscordClient _, GuildMemberUpdateEventArgs args) => UpdateDisplayName(args.Guild, args.Member); + public static Task OnMemberAdded(DiscordClient _, GuildMemberAddEventArgs args) => UpdateDisplayName(args.Guild, args.Member); + + private static async Task UpdateDisplayName(DiscordGuild guild, DiscordMember guildMember) { - public static Task OnMemberUpdated(DiscordClient _, GuildMemberUpdateEventArgs args) => UpdateDisplayName(args.Guild, args.Member); - public static Task OnMemberAdded(DiscordClient _, GuildMemberAddEventArgs args) => UpdateDisplayName(args.Guild, args.Member); - - private static async Task UpdateDisplayName(DiscordGuild guild, DiscordMember guildMember) + try { - try - { - if (guildMember.IsWhitelisted()) - return; + if (guildMember.IsWhitelisted()) + return; - if (guild.Permissions?.HasFlag(Permissions.ChangeNickname) is false) - return; + if (guild.Permissions?.HasFlag(Permissions.ChangeNickname) is false) + return; - await using var db = new BotDb(); - var forcedNickname = await db.ForcedNicknames.AsNoTracking().FirstOrDefaultAsync(x => x.UserId == guildMember.Id && x.GuildId == guildMember.Guild.Id).ConfigureAwait(false); - if (forcedNickname is null) - return; + await using var db = new BotDb(); + var forcedNickname = await db.ForcedNicknames.AsNoTracking().FirstOrDefaultAsync(x => x.UserId == guildMember.Id && x.GuildId == guildMember.Guild.Id).ConfigureAwait(false); + if (forcedNickname is null) + return; - if (guildMember.DisplayName == forcedNickname.Nickname) - return; + if (guildMember.DisplayName == forcedNickname.Nickname) + return; - Config.Log.Debug($"Expected nickname {forcedNickname.Nickname}, but was {guildMember.Nickname}. Renaming..."); - await guildMember.ModifyAsync(mem => mem.Nickname = forcedNickname.Nickname).ConfigureAwait(false); - Config.Log.Info($"Enforced nickname {forcedNickname.Nickname} for user {guildMember.Id} ({guildMember.Username}#{guildMember.Discriminator})"); - } - catch (Exception e) - { - Config.Log.Error(e); - } + Config.Log.Debug($"Expected nickname {forcedNickname.Nickname}, but was {guildMember.Nickname}. Renaming..."); + await guildMember.ModifyAsync(mem => mem.Nickname = forcedNickname.Nickname).ConfigureAwait(false); + Config.Log.Info($"Enforced nickname {forcedNickname.Nickname} for user {guildMember.Id} ({guildMember.Username}#{guildMember.Discriminator})"); } - - public static async Task MonitorAsync(DiscordClient client, bool once = false) + catch (Exception e) { - do - { - if (!once) - await Task.Delay(Config.ForcedNicknamesRecheckTimeInHours, Config.Cts.Token).ConfigureAwait(false); - if (!await Moderation.Audit.CheckLock.WaitAsync(0).ConfigureAwait(false)) - continue; - - try - { - foreach (var guild in client.Guilds.Values) - try - { - if (guild.Permissions?.HasFlag(Permissions.ChangeNickname) is false) - continue; - - await using var db = new BotDb(); - var forcedNicknames = await db.ForcedNicknames - .Where(mem => mem.GuildId == guild.Id) - .ToListAsync() - .ConfigureAwait(false); - if (forcedNicknames.Count == 0) - continue; - - foreach (var forced in forcedNicknames) - { - var member = client.GetMember(guild, forced.UserId); - if (member is null || member.DisplayName == forced.Nickname) - continue; - - try - { - await member.ModifyAsync(mem => mem.Nickname = forced.Nickname).ConfigureAwait(false); - Config.Log.Info($"Enforced nickname {forced.Nickname} for user {member.Id} ({member.Username}#{member.Discriminator})"); - } - catch { } - } - } - catch (Exception e) - { - Config.Log.Error(e); - } - } - finally - { - Moderation.Audit.CheckLock.Release(); - } - } while (!Config.Cts.IsCancellationRequested && !once); + Config.Log.Error(e); } } + + public static async Task MonitorAsync(DiscordClient client, bool once = false) + { + do + { + if (!once) + await Task.Delay(Config.ForcedNicknamesRecheckTimeInHours, Config.Cts.Token).ConfigureAwait(false); + if (!await Moderation.Audit.CheckLock.WaitAsync(0).ConfigureAwait(false)) + continue; + + try + { + foreach (var guild in client.Guilds.Values) + try + { + if (guild.Permissions?.HasFlag(Permissions.ChangeNickname) is false) + continue; + + await using var db = new BotDb(); + var forcedNicknames = await db.ForcedNicknames + .Where(mem => mem.GuildId == guild.Id) + .ToListAsync() + .ConfigureAwait(false); + if (forcedNicknames.Count == 0) + continue; + + foreach (var forced in forcedNicknames) + { + var member = client.GetMember(guild, forced.UserId); + if (member is null || member.DisplayName == forced.Nickname) + continue; + + try + { + await member.ModifyAsync(mem => mem.Nickname = forced.Nickname).ConfigureAwait(false); + Config.Log.Info($"Enforced nickname {forced.Nickname} for user {member.Id} ({member.Username}#{member.Discriminator})"); + } + catch { } + } + } + catch (Exception e) + { + Config.Log.Error(e); + } + } + finally + { + Moderation.Audit.CheckLock.Release(); + } + } while (!Config.Cts.IsCancellationRequested && !once); + } } \ No newline at end of file diff --git a/CompatBot/EventHandlers/UsernameZalgoMonitor.cs b/CompatBot/EventHandlers/UsernameZalgoMonitor.cs index c7bdafeb..07712578 100644 --- a/CompatBot/EventHandlers/UsernameZalgoMonitor.cs +++ b/CompatBot/EventHandlers/UsernameZalgoMonitor.cs @@ -11,201 +11,200 @@ using DSharpPlus; using DSharpPlus.Entities; using DSharpPlus.EventArgs; -namespace CompatBot.EventHandlers +namespace CompatBot.EventHandlers; + +public static class UsernameZalgoMonitor { - public static class UsernameZalgoMonitor + private static readonly HashSet OversizedChars = new() { - private static readonly HashSet OversizedChars = new() - { - '꧁', '꧂', '⎝', '⎠', '⧹', '⧸', '⎛', '⎞', '﷽', '⸻', 'ဪ', '꧅', '꧄', '˞', - }; + '꧁', '꧂', '⎝', '⎠', '⧹', '⧸', '⎛', '⎞', '﷽', '⸻', 'ဪ', '꧅', '꧄', '˞', + }; - public static async Task OnUserUpdated(DiscordClient c, UserUpdateEventArgs args) + public static async Task OnUserUpdated(DiscordClient c, UserUpdateEventArgs args) + { + try { - try + if (c.GetMember(args.UserAfter) is DiscordMember m + && NeedsRename(m.DisplayName)) { - if (c.GetMember(args.UserAfter) is DiscordMember m - && NeedsRename(m.DisplayName)) - { - var suggestedName = StripZalgo(m.DisplayName, m.Username, m.Id).Sanitize(); - await c.ReportAsync("🔣 Potential display name issue", - $"User {m.GetMentionWithNickname()} has changed their __username__ and is now shown as **{m.DisplayName.Sanitize()}**\nAutomatically renamed to: **{suggestedName}**", - null, - ReportSeverity.Low); - await DmAndRenameUserAsync(c, m, suggestedName).ConfigureAwait(false); - } - } - catch (Exception e) - { - Config.Log.Error(e); + var suggestedName = StripZalgo(m.DisplayName, m.Username, m.Id).Sanitize(); + await c.ReportAsync("🔣 Potential display name issue", + $"User {m.GetMentionWithNickname()} has changed their __username__ and is now shown as **{m.DisplayName.Sanitize()}**\nAutomatically renamed to: **{suggestedName}**", + null, + ReportSeverity.Low); + await DmAndRenameUserAsync(c, m, suggestedName).ConfigureAwait(false); } } - - public static async Task OnMemberUpdated(DiscordClient c, GuildMemberUpdateEventArgs args) + catch (Exception e) { - try - { - //member object most likely will not be updated in client cache at this moment - string? fallback; - if (args.NicknameAfter is string name) - fallback = args.Member.Username; - else - { - name = args.Member.Username; - fallback = null; - } + Config.Log.Error(e); + } + } - var member = await args.Guild.GetMemberAsync(args.Member.Id).ConfigureAwait(false) ?? args.Member; - if (NeedsRename(name)) - { - var suggestedName = StripZalgo(name, fallback, args.Member.Id).Sanitize(); - await c.ReportAsync("🔣 Potential display name issue", - $"Member {member.GetMentionWithNickname()} has changed their __display name__ and is now shown as **{name.Sanitize()}**\nAutomatically renamed to: **{suggestedName}**", - null, - ReportSeverity.Low); - await DmAndRenameUserAsync(c, member, suggestedName).ConfigureAwait(false); - } - } - catch (Exception e) + public static async Task OnMemberUpdated(DiscordClient c, GuildMemberUpdateEventArgs args) + { + try + { + //member object most likely will not be updated in client cache at this moment + string? fallback; + if (args.NicknameAfter is string name) + fallback = args.Member.Username; + else { - Config.Log.Error(e); + name = args.Member.Username; + fallback = null; + } + + var member = await args.Guild.GetMemberAsync(args.Member.Id).ConfigureAwait(false) ?? args.Member; + if (NeedsRename(name)) + { + var suggestedName = StripZalgo(name, fallback, args.Member.Id).Sanitize(); + await c.ReportAsync("🔣 Potential display name issue", + $"Member {member.GetMentionWithNickname()} has changed their __display name__ and is now shown as **{name.Sanitize()}**\nAutomatically renamed to: **{suggestedName}**", + null, + ReportSeverity.Low); + await DmAndRenameUserAsync(c, member, suggestedName).ConfigureAwait(false); } } - - public static async Task OnMemberAdded(DiscordClient c, GuildMemberAddEventArgs args) + catch (Exception e) { - try + Config.Log.Error(e); + } + } + + public static async Task OnMemberAdded(DiscordClient c, GuildMemberAddEventArgs args) + { + try + { + var name = args.Member.DisplayName; + if (NeedsRename(name)) { - var name = args.Member.DisplayName; - if (NeedsRename(name)) - { - var suggestedName = StripZalgo(name, args.Member.Username, args.Member.Id).Sanitize(); - await c.ReportAsync("🔣 Potential display name issue", - $"New member joined the server: {args.Member.GetMentionWithNickname()} and is shown as **{name.Sanitize()}**\nAutomatically renamed to: **{suggestedName}**", - null, - ReportSeverity.Low); + var suggestedName = StripZalgo(name, args.Member.Username, args.Member.Id).Sanitize(); + await c.ReportAsync("🔣 Potential display name issue", + $"New member joined the server: {args.Member.GetMentionWithNickname()} and is shown as **{name.Sanitize()}**\nAutomatically renamed to: **{suggestedName}**", + null, + ReportSeverity.Low); await DmAndRenameUserAsync(c, args.Member, suggestedName).ConfigureAwait(false); - } - } - catch (Exception e) - { - Config.Log.Error(e); } } - - public static bool NeedsRename(string displayName) + catch (Exception e) { - displayName = displayName.Normalize().TrimEager(); - return displayName != StripZalgo(displayName, null, 0ul, NormalizationForm.FormC, 3); + Config.Log.Error(e); } + } - private static async Task DmAndRenameUserAsync(DiscordClient client, DiscordMember member, string suggestedName) + public static bool NeedsRename(string displayName) + { + displayName = displayName.Normalize().TrimEager(); + return displayName != StripZalgo(displayName, null, 0ul, NormalizationForm.FormC, 3); + } + + private static async Task DmAndRenameUserAsync(DiscordClient client, DiscordMember member, string suggestedName) + { + try { - try - { - var renameTask = member.ModifyAsync(m => m.Nickname = suggestedName); - Config.Log.Info($"Renamed {member.Username}#{member.Discriminator} ({member.Id}) to {suggestedName}"); - var rulesChannel = await client.GetChannelAsync(Config.BotRulesChannelId).ConfigureAwait(false); - var msg = $"Hello, your current _display name_ is breaking {rulesChannel.Mention} #7, so you have been renamed to `{suggestedName}`.\n" + - "I'm not perfect and can't clean all the junk in names in some cases, so change your nickname at your discretion.\n" + - "You can change your _display name_ by clicking on the server name at the top left and selecting **Change Nickname**."; - var dm = await member.CreateDmChannelAsync().ConfigureAwait(false); - await dm.SendMessageAsync(msg).ConfigureAwait(false); - await renameTask.ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e); - } + var renameTask = member.ModifyAsync(m => m.Nickname = suggestedName); + Config.Log.Info($"Renamed {member.Username}#{member.Discriminator} ({member.Id}) to {suggestedName}"); + var rulesChannel = await client.GetChannelAsync(Config.BotRulesChannelId).ConfigureAwait(false); + var msg = $"Hello, your current _display name_ is breaking {rulesChannel.Mention} #7, so you have been renamed to `{suggestedName}`.\n" + + "I'm not perfect and can't clean all the junk in names in some cases, so change your nickname at your discretion.\n" + + "You can change your _display name_ by clicking on the server name at the top left and selecting **Change Nickname**."; + var dm = await member.CreateDmChannelAsync().ConfigureAwait(false); + await dm.SendMessageAsync(msg).ConfigureAwait(false); + await renameTask.ConfigureAwait(false); } - - public static string StripZalgo(string displayName, string? userName, ulong userId, NormalizationForm normalizationForm = NormalizationForm.FormD, int level = 0) + catch (Exception e) { - const int minNicknameLength = 2; - displayName = displayName.Normalize(normalizationForm).TrimEager(); - if (displayName is null or {Length: = 0x016a0 and < 0x01700 // Runic + or >= 0x101d0 and < 0x10200 // Phaistos Disc + or >= 0x10380 and < 0x10400 // Ugaritic and Old Persian + or >= 0x12000 and < 0x13000) // Cuneiform + continue; + + builder.Append(highSurrogate).Append(c); + hasNormalCharacterBefore = true; + consecutive = 0; + } + break; + + case UnicodeCategory.OtherNotAssigned when c >= 0xdb40: + skipLowSurrogate = true; + break; + + default: + if (char.IsLowSurrogate(c) && skipLowSurrogate) + skipLowSurrogate = false; + else + { + if (!OversizedChars.Contains(c)) + { builder.Append(c); - break; - - case UnicodeCategory.Control: - case UnicodeCategory.Format: - break; - - case UnicodeCategory.Surrogate: - if (char.IsHighSurrogate(c)) - { - codePoint = 0x10000 | ((c & 0x3ff) << 10); - highSurrogate = c; - } - else - { - codePoint |= c & 0x3ff; - if (codePoint is >= 0x016a0 and < 0x01700 // Runic - or >= 0x101d0 and < 0x10200 // Phaistos Disc - or >= 0x10380 and < 0x10400 // Ugaritic and Old Persian - or >= 0x12000 and < 0x13000) // Cuneiform - continue; - - builder.Append(highSurrogate).Append(c); hasNormalCharacterBefore = true; consecutive = 0; } - break; - - case UnicodeCategory.OtherNotAssigned when c >= 0xdb40: - skipLowSurrogate = true; - break; - - default: - if (char.IsLowSurrogate(c) && skipLowSurrogate) - skipLowSurrogate = false; - else - { - if (!OversizedChars.Contains(c)) - { - builder.Append(c); - hasNormalCharacterBefore = true; - consecutive = 0; - } - } - break; - } + } + break; } - var result = builder.ToString().TrimEager(); - if (result is null or {Length: 0 && args[0] == "--dry-run") { - Config.TelemetryClient?.TrackEvent("startup"); + await OpenSslConfigurator.CheckAndFixSystemConfigAsync().ConfigureAwait(false); + Console.WriteLine("Database path: " + Path.GetDirectoryName(Path.GetFullPath(DbImporter.GetDbPath("fake.db", Environment.SpecialFolder.ApplicationData)))); + if (Assembly.GetEntryAssembly()?.GetCustomAttribute() != null) + Console.WriteLine("Bot config path: " + Path.GetDirectoryName(Path.GetFullPath(Config.GoogleApiConfigPath))); + return; + } - Console.WriteLine("Confinement: " + SandboxDetector.Detect()); - if (args.Length > 0 && args[0] == "--dry-run") + if (Environment.ProcessId == 0) + Config.Log.Info("Well, this was unexpected"); + var singleInstanceCheckThread = new Thread(() => + { + using var instanceLock = new Mutex(false, @"Global\RPCS3 Compatibility Bot"); + if (instanceLock.WaitOne(1000)) + try + { + InstanceCheck.Release(); + ShutdownCheck.Wait(); + } + finally + { + instanceLock.ReleaseMutex(); + } + }); + try + { + singleInstanceCheckThread.Start(); + if (!await InstanceCheck.WaitAsync(1000).ConfigureAwait(false)) { - await OpenSslConfigurator.CheckAndFixSystemConfigAsync().ConfigureAwait(false); - Console.WriteLine("Database path: " + Path.GetDirectoryName(Path.GetFullPath(DbImporter.GetDbPath("fake.db", Environment.SpecialFolder.ApplicationData)))); - if (Assembly.GetEntryAssembly()?.GetCustomAttribute() != null) - Console.WriteLine("Bot config path: " + Path.GetDirectoryName(Path.GetFullPath(Config.GoogleApiConfigPath))); + Config.Log.Fatal("Another instance is already running."); return; } - if (Environment.ProcessId == 0) - Config.Log.Info("Well, this was unexpected"); - var singleInstanceCheckThread = new Thread(() => + if (string.IsNullOrEmpty(Config.Token) || Config.Token.Length < 16) { - using var instanceLock = new Mutex(false, @"Global\RPCS3 Compatibility Bot"); - if (instanceLock.WaitOne(1000)) - try - { - InstanceCheck.Release(); - ShutdownCheck.Wait(); - } - finally - { - instanceLock.ReleaseMutex(); - } - }); - try + Config.Log.Fatal("No token was specified."); + return; + } + + if (SandboxDetector.Detect() == SandboxType.Docker) { - singleInstanceCheckThread.Start(); - if (!await InstanceCheck.WaitAsync(1000).ConfigureAwait(false)) - { - Config.Log.Fatal("Another instance is already running."); - return; - } - - if (string.IsNullOrEmpty(Config.Token) || Config.Token.Length < 16) - { - Config.Log.Fatal("No token was specified."); - return; - } - - if (SandboxDetector.Detect() == SandboxType.Docker) - { - Config.Log.Info("Checking OpenSSL system configuration..."); - await OpenSslConfigurator.CheckAndFixSystemConfigAsync().ConfigureAwait(false); + Config.Log.Info("Checking OpenSSL system configuration..."); + await OpenSslConfigurator.CheckAndFixSystemConfigAsync().ConfigureAwait(false); - Config.Log.Info("Checking for updates..."); - try + Config.Log.Info("Checking for updates..."); + try + { + var (updated, stdout) = await Sudo.Bot.UpdateAsync().ConfigureAwait(false); + if (!string.IsNullOrEmpty(stdout) && updated) + Config.Log.Debug(stdout); + if (updated) { - var (updated, stdout) = await Sudo.Bot.UpdateAsync().ConfigureAwait(false); - if (!string.IsNullOrEmpty(stdout) && updated) - Config.Log.Debug(stdout); - if (updated) - { - Sudo.Bot.Restart(InvalidChannelId, "Restarted due to new bot updates not present in this Docker image"); - return; - } - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to check for updates"); + Sudo.Bot.Restart(InvalidChannelId, "Restarted due to new bot updates not present in this Docker image"); + return; } } + catch (Exception e) + { + Config.Log.Error(e, "Failed to check for updates"); + } + } - if (!await DbImporter.UpgradeAsync(Config.Cts.Token).ConfigureAwait(false)) - return; + if (!await DbImporter.UpgradeAsync(Config.Cts.Token).ConfigureAwait(false)) + return; - await SqlConfiguration.RestoreAsync().ConfigureAwait(false); - Config.Log.Debug("Restored configuration variables from persistent storage"); + await SqlConfiguration.RestoreAsync().ConfigureAwait(false); + Config.Log.Debug("Restored configuration variables from persistent storage"); - await StatsStorage.RestoreAsync().ConfigureAwait(false); - Config.Log.Debug("Restored stats from persistent storage"); + await StatsStorage.RestoreAsync().ConfigureAwait(false); + Config.Log.Debug("Restored stats from persistent storage"); - var backgroundTasks = Task.WhenAll( - AmdDriverVersionProvider.RefreshAsync(), + var backgroundTasks = Task.WhenAll( + AmdDriverVersionProvider.RefreshAsync(), #if !DEBUG ThumbScrapper.GameTdbScraper.RunAsync(Config.Cts.Token), //TitleUpdateInfoProvider.RefreshGameUpdateInfoAsync(Config.Cts.Token), #endif - StatsStorage.BackgroundSaveAsync(), - CompatList.ImportCompatListAsync(), - Config.GetAzureDevOpsClient().GetPipelineDurationAsync(Config.Cts.Token), - CirrusCi.GetPipelineDurationAsync(Config.Cts.Token) - ); + StatsStorage.BackgroundSaveAsync(), + CompatList.ImportCompatListAsync(), + Config.GetAzureDevOpsClient().GetPipelineDurationAsync(Config.Cts.Token), + CirrusCi.GetPipelineDurationAsync(Config.Cts.Token) + ); - try - { - if (!Directory.Exists(Config.IrdCachePath)) - Directory.CreateDirectory(Config.IrdCachePath); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Failed to create new folder {Config.IrdCachePath}: {e.Message}"); - } + try + { + if (!Directory.Exists(Config.IrdCachePath)) + Directory.CreateDirectory(Config.IrdCachePath); + } + catch (Exception e) + { + Config.Log.Warn(e, $"Failed to create new folder {Config.IrdCachePath}: {e.Message}"); + } - var config = new DiscordConfiguration - { - Token = Config.Token, - TokenType = TokenType.Bot, - MessageCacheSize = Config.MessageCacheSize, - LoggerFactory = Config.LoggerFactory, - Intents = DiscordIntents.All, - }; - using var client = new DiscordClient(config); - var slashCommands = client.UseSlashCommands(); - // Only register to rpcs3 guild for now. - slashCommands.RegisterCommands(Config.BotGuildId); + var config = new DiscordConfiguration + { + Token = Config.Token, + TokenType = TokenType.Bot, + MessageCacheSize = Config.MessageCacheSize, + LoggerFactory = Config.LoggerFactory, + Intents = DiscordIntents.All, + }; + using var client = new DiscordClient(config); + var slashCommands = client.UseSlashCommands(); + // Only register to rpcs3 guild for now. + slashCommands.RegisterCommands(Config.BotGuildId); - var commands = client.UseCommandsNext(new() - { - StringPrefixes = new[] {Config.CommandPrefix, Config.AutoRemoveCommandPrefix}, - Services = new ServiceCollection().BuildServiceProvider(), - }); - commands.RegisterConverter(new TextOnlyDiscordChannelConverter()); + var commands = client.UseCommandsNext(new() + { + StringPrefixes = new[] {Config.CommandPrefix, Config.AutoRemoveCommandPrefix}, + Services = new ServiceCollection().BuildServiceProvider(), + }); + commands.RegisterConverter(new TextOnlyDiscordChannelConverter()); #if DEBUG - commands.RegisterCommands(); + commands.RegisterCommands(); #endif - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); - commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); - if (!string.IsNullOrEmpty(Config.AzureComputerVisionKey)) - commands.RegisterCommands(); + if (!string.IsNullOrEmpty(Config.AzureComputerVisionKey)) + commands.RegisterCommands(); - commands.CommandErrored += UnknownCommandHandler.OnError; + commands.CommandErrored += UnknownCommandHandler.OnError; - client.UseInteractivity(new()); + client.UseInteractivity(new()); - client.Ready += async (c, _) => - { - Config.Log.Info("Bot is ready to serve!"); - Config.Log.Info(""); - Config.Log.Info($"Bot user id : {c.CurrentUser.Id} ({c.CurrentUser.Username})"); - var owners = c.CurrentApplication.Owners.ToList(); - var msg = new StringBuilder($"Bot admin id{(owners.Count == 1 ? "": "s")}:"); - if (owners.Count > 1) - msg.AppendLine(); - await using var db = new BotDb(); - foreach (var owner in owners) - { - msg.AppendLine($"\t{owner.Id} ({owner.Username ?? "???"}#{owner.Discriminator ?? "????"})"); - if (!await db.Moderator.AnyAsync(m => m.DiscordId == owner.Id, Config.Cts.Token).ConfigureAwait(false)) - await db.Moderator.AddAsync(new() {DiscordId = owner.Id, Sudoer = true}, Config.Cts.Token).ConfigureAwait(false); - } - await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - Config.Log.Info(msg.ToString().TrimEnd); - Config.Log.Info(""); - }; - client.GuildAvailable += async (c, gaArgs) => + client.Ready += async (c, _) => + { + Config.Log.Info("Bot is ready to serve!"); + Config.Log.Info(""); + Config.Log.Info($"Bot user id : {c.CurrentUser.Id} ({c.CurrentUser.Username})"); + var owners = c.CurrentApplication.Owners.ToList(); + var msg = new StringBuilder($"Bot admin id{(owners.Count == 1 ? "": "s")}:"); + if (owners.Count > 1) + msg.AppendLine(); + await using var db = new BotDb(); + foreach (var owner in owners) + { + msg.AppendLine($"\t{owner.Id} ({owner.Username ?? "???"}#{owner.Discriminator ?? "????"})"); + if (!await db.Moderator.AnyAsync(m => m.DiscordId == owner.Id, Config.Cts.Token).ConfigureAwait(false)) + await db.Moderator.AddAsync(new() {DiscordId = owner.Id, Sudoer = true}, Config.Cts.Token).ConfigureAwait(false); + } + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + Config.Log.Info(msg.ToString().TrimEnd); + Config.Log.Info(""); + }; + client.GuildAvailable += async (c, gaArgs) => + { + await BotStatusMonitor.RefreshAsync(c).ConfigureAwait(false); + Watchdog.DisconnectTimestamps.Clear(); + Watchdog.TimeSinceLastIncomingMessage.Restart(); + if (gaArgs.Guild.Id != Config.BotGuildId) { - await BotStatusMonitor.RefreshAsync(c).ConfigureAwait(false); - Watchdog.DisconnectTimestamps.Clear(); - Watchdog.TimeSinceLastIncomingMessage.Restart(); - if (gaArgs.Guild.Id != Config.BotGuildId) - { #if DEBUG - Config.Log.Warn($"Unknown discord server {gaArgs.Guild.Id} ({gaArgs.Guild.Name})"); + Config.Log.Warn($"Unknown discord server {gaArgs.Guild.Id} ({gaArgs.Guild.Name})"); #else Config.Log.Warn($"Unknown discord server {gaArgs.Guild.Id} ({gaArgs.Guild.Name}), leaving..."); await gaArgs.Guild.LeaveAsync().ConfigureAwait(false); #endif - return; - } + return; + } - Config.Log.Info($"Server {gaArgs.Guild.Name} is available now"); - Config.Log.Info($"Checking moderation backlogs in {gaArgs.Guild.Name}..."); - try - { - await Task.WhenAll( - Starbucks.CheckBacklogAsync(c, gaArgs.Guild).ContinueWith(_ => Config.Log.Info($"Starbucks backlog checked in {gaArgs.Guild.Name}."), TaskScheduler.Default), - DiscordInviteFilter.CheckBacklogAsync(c, gaArgs.Guild).ContinueWith(_ => Config.Log.Info($"Discord invites backlog checked in {gaArgs.Guild.Name}."), TaskScheduler.Default) - ).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, "Error running backlog tasks"); - } - Config.Log.Info($"All moderation backlogs checked in {gaArgs.Guild.Name}."); - }; - client.GuildAvailable += (c, _) => UsernameValidationMonitor.MonitorAsync(c, true); - client.GuildUnavailable += (_, guArgs) => + Config.Log.Info($"Server {gaArgs.Guild.Name} is available now"); + Config.Log.Info($"Checking moderation backlogs in {gaArgs.Guild.Name}..."); + try { - Config.Log.Warn($"{guArgs.Guild.Name} is unavailable"); - return Task.CompletedTask; - }; + await Task.WhenAll( + Starbucks.CheckBacklogAsync(c, gaArgs.Guild).ContinueWith(_ => Config.Log.Info($"Starbucks backlog checked in {gaArgs.Guild.Name}."), TaskScheduler.Default), + DiscordInviteFilter.CheckBacklogAsync(c, gaArgs.Guild).ContinueWith(_ => Config.Log.Info($"Discord invites backlog checked in {gaArgs.Guild.Name}."), TaskScheduler.Default) + ).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Warn(e, "Error running backlog tasks"); + } + Config.Log.Info($"All moderation backlogs checked in {gaArgs.Guild.Name}."); + }; + client.GuildAvailable += (c, _) => UsernameValidationMonitor.MonitorAsync(c, true); + client.GuildUnavailable += (_, guArgs) => + { + Config.Log.Warn($"{guArgs.Guild.Name} is unavailable"); + return Task.CompletedTask; + }; #if !DEBUG /* client.GuildDownloadCompleted += async gdcArgs => @@ -249,153 +249,152 @@ namespace CompatBot }; */ #endif - client.MessageReactionAdded += Starbucks.Handler; - client.MessageReactionAdded += ContentFilterMonitor.OnReaction; + client.MessageReactionAdded += Starbucks.Handler; + client.MessageReactionAdded += ContentFilterMonitor.OnReaction; - client.MessageCreated += Watchdog.OnMessageCreated; - client.MessageCreated += ContentFilterMonitor.OnMessageCreated; // should be first - client.MessageCreated += GlobalMessageCache.OnMessageCreated; - var mediaScreenshotMonitor = new MediaScreenshotMonitor(client); - if (!string.IsNullOrEmpty(Config.AzureComputerVisionKey)) - client.MessageCreated += mediaScreenshotMonitor.OnMessageCreated; - client.MessageCreated += ProductCodeLookup.OnMessageCreated; - client.MessageCreated += LogParsingHandler.OnMessageCreated; - client.MessageCreated += LogAsTextMonitor.OnMessageCreated; - client.MessageCreated += DiscordInviteFilter.OnMessageCreated; - client.MessageCreated += PostLogHelpHandler.OnMessageCreated; - client.MessageCreated += BotReactionsHandler.OnMessageCreated; - client.MessageCreated += GithubLinksHandler.OnMessageCreated; - client.MessageCreated += NewBuildsMonitor.OnMessageCreated; - client.MessageCreated += TableFlipMonitor.OnMessageCreated; - client.MessageCreated += IsTheGamePlayableHandler.OnMessageCreated; - client.MessageCreated += EmpathySimulationHandler.OnMessageCreated; + client.MessageCreated += Watchdog.OnMessageCreated; + client.MessageCreated += ContentFilterMonitor.OnMessageCreated; // should be first + client.MessageCreated += GlobalMessageCache.OnMessageCreated; + var mediaScreenshotMonitor = new MediaScreenshotMonitor(client); + if (!string.IsNullOrEmpty(Config.AzureComputerVisionKey)) + client.MessageCreated += mediaScreenshotMonitor.OnMessageCreated; + client.MessageCreated += ProductCodeLookup.OnMessageCreated; + client.MessageCreated += LogParsingHandler.OnMessageCreated; + client.MessageCreated += LogAsTextMonitor.OnMessageCreated; + client.MessageCreated += DiscordInviteFilter.OnMessageCreated; + client.MessageCreated += PostLogHelpHandler.OnMessageCreated; + client.MessageCreated += BotReactionsHandler.OnMessageCreated; + client.MessageCreated += GithubLinksHandler.OnMessageCreated; + client.MessageCreated += NewBuildsMonitor.OnMessageCreated; + client.MessageCreated += TableFlipMonitor.OnMessageCreated; + client.MessageCreated += IsTheGamePlayableHandler.OnMessageCreated; + client.MessageCreated += EmpathySimulationHandler.OnMessageCreated; - client.MessageUpdated += GlobalMessageCache.OnMessageUpdated; - client.MessageUpdated += ContentFilterMonitor.OnMessageUpdated; - client.MessageUpdated += DiscordInviteFilter.OnMessageUpdated; - client.MessageUpdated += EmpathySimulationHandler.OnMessageUpdated; + client.MessageUpdated += GlobalMessageCache.OnMessageUpdated; + client.MessageUpdated += ContentFilterMonitor.OnMessageUpdated; + client.MessageUpdated += DiscordInviteFilter.OnMessageUpdated; + client.MessageUpdated += EmpathySimulationHandler.OnMessageUpdated; - client.MessageDeleted += GlobalMessageCache.OnMessageDeleted; - if (Config.DeletedMessagesLogChannelId > 0) - client.MessageDeleted += DeletedMessagesMonitor.OnMessageDeleted; - client.MessageDeleted += ThumbnailCacheMonitor.OnMessageDeleted; - client.MessageDeleted += EmpathySimulationHandler.OnMessageDeleted; + client.MessageDeleted += GlobalMessageCache.OnMessageDeleted; + if (Config.DeletedMessagesLogChannelId > 0) + client.MessageDeleted += DeletedMessagesMonitor.OnMessageDeleted; + client.MessageDeleted += ThumbnailCacheMonitor.OnMessageDeleted; + client.MessageDeleted += EmpathySimulationHandler.OnMessageDeleted; - client.MessagesBulkDeleted += GlobalMessageCache.OnMessagesBulkDeleted; + client.MessagesBulkDeleted += GlobalMessageCache.OnMessagesBulkDeleted; - client.UserUpdated += UsernameSpoofMonitor.OnUserUpdated; - client.UserUpdated += UsernameZalgoMonitor.OnUserUpdated; + client.UserUpdated += UsernameSpoofMonitor.OnUserUpdated; + client.UserUpdated += UsernameZalgoMonitor.OnUserUpdated; - client.GuildMemberAdded += Greeter.OnMemberAdded; - client.GuildMemberAdded += UsernameSpoofMonitor.OnMemberAdded; - client.GuildMemberAdded += UsernameZalgoMonitor.OnMemberAdded; - client.GuildMemberAdded += UsernameValidationMonitor.OnMemberAdded; - client.GuildMemberAdded += UsernameRaidMonitor.OnMemberAdded; + client.GuildMemberAdded += Greeter.OnMemberAdded; + client.GuildMemberAdded += UsernameSpoofMonitor.OnMemberAdded; + client.GuildMemberAdded += UsernameZalgoMonitor.OnMemberAdded; + client.GuildMemberAdded += UsernameValidationMonitor.OnMemberAdded; + client.GuildMemberAdded += UsernameRaidMonitor.OnMemberAdded; - client.GuildMemberUpdated += UsernameSpoofMonitor.OnMemberUpdated; - client.GuildMemberUpdated += UsernameZalgoMonitor.OnMemberUpdated; - client.GuildMemberUpdated += UsernameValidationMonitor.OnMemberUpdated; - client.GuildMemberUpdated += UsernameRaidMonitor.OnMemberUpdated; + client.GuildMemberUpdated += UsernameSpoofMonitor.OnMemberUpdated; + client.GuildMemberUpdated += UsernameZalgoMonitor.OnMemberUpdated; + client.GuildMemberUpdated += UsernameValidationMonitor.OnMemberUpdated; + client.GuildMemberUpdated += UsernameRaidMonitor.OnMemberUpdated; #if DEBUG - client.ComponentInteractionCreated += (_, args) => - { - Config.Log.Debug($"ComponentInteraction: type: {args.Interaction.Type}, id: {args.Interaction.Data.CustomId}, user: {args.Interaction.User}"); - return Task.CompletedTask; - }; + client.ComponentInteractionCreated += (_, args) => + { + Config.Log.Debug($"ComponentInteraction: type: {args.Interaction.Type}, id: {args.Interaction.Data.CustomId}, user: {args.Interaction.User}"); + return Task.CompletedTask; + }; #endif - client.ComponentInteractionCreated += GlobalButtonHandler.OnComponentInteraction; + client.ComponentInteractionCreated += GlobalButtonHandler.OnComponentInteraction; - Watchdog.DisconnectTimestamps.Enqueue(DateTime.UtcNow); + Watchdog.DisconnectTimestamps.Enqueue(DateTime.UtcNow); - try - { - await client.ConnectAsync().ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Error(e, "Failed to connect to Discord: " + e.Message); - throw; - } - - ulong? channelId = null; - string? restartMsg = null; - await using (var db = new BotDb()) - { - var chState = db.BotState.FirstOrDefault(k => k.Key == "bot-restart-channel"); - if (chState != null) - { - if (ulong.TryParse(chState.Value, out var ch)) - channelId = ch; - db.BotState.Remove(chState); - } - var msgState = db.BotState.FirstOrDefault(i => i.Key == "bot-restart-msg"); - if (msgState != null) - { - restartMsg = msgState.Value; - db.BotState.Remove(msgState); - } - await db.SaveChangesAsync().ConfigureAwait(false); - } - if (string.IsNullOrEmpty(restartMsg)) - restartMsg = null; - - if (channelId.HasValue) - { - Config.Log.Info($"Found channelId {channelId}"); - DiscordChannel channel; - if (channelId == InvalidChannelId) - { - channel = await client.GetChannelAsync(Config.ThumbnailSpamId).ConfigureAwait(false); - await channel.SendMessageAsync(restartMsg ?? "Bot has suffered some catastrophic failure and was restarted").ConfigureAwait(false); - } - else - { - channel = await client.GetChannelAsync(channelId.Value).ConfigureAwait(false); - await channel.SendMessageAsync("Bot is up and running").ConfigureAwait(false); - } - } - else - { - Config.Log.Debug($"Args count: {args.Length}"); - var pArgs = args.Select(a => a == Config.Token ? "" : $"[{a}]"); - Config.Log.Debug("Args: " + string.Join(" ", pArgs)); - } - - Config.Log.Debug("Running RPCS3 update check thread"); - backgroundTasks = Task.WhenAll( - backgroundTasks, - NewBuildsMonitor.MonitorAsync(client), - Watchdog.Watch(client), - InviteWhitelistProvider.CleanupAsync(client), - UsernameValidationMonitor.MonitorAsync(client), - Psn.Check.MonitorFwUpdates(client, Config.Cts.Token), - Watchdog.SendMetrics(client), - Watchdog.CheckGCStats(), - mediaScreenshotMonitor.ProcessWorkQueue() - ); - - while (!Config.Cts.IsCancellationRequested) - { - if (client.Ping > 1000) - Config.Log.Warn($"High ping detected: {client.Ping}"); - await Task.Delay(TimeSpan.FromMinutes(1), Config.Cts.Token).ContinueWith(_ => {/* in case it was cancelled */}, TaskScheduler.Default).ConfigureAwait(false); - } - await backgroundTasks.ConfigureAwait(false); + try + { + await client.ConnectAsync().ConfigureAwait(false); } catch (Exception e) { - if (!Config.InMemorySettings.ContainsKey("shutdown")) - Config.Log.Fatal(e, "Experienced catastrophic failure, attempting to restart..."); + Config.Log.Error(e, "Failed to connect to Discord: " + e.Message); + throw; } - finally + + ulong? channelId = null; + string? restartMsg = null; + await using (var db = new BotDb()) { - Config.TelemetryClient?.Flush(); - ShutdownCheck.Release(); - if (singleInstanceCheckThread.IsAlive) - singleInstanceCheckThread.Join(100); + var chState = db.BotState.FirstOrDefault(k => k.Key == "bot-restart-channel"); + if (chState != null) + { + if (ulong.TryParse(chState.Value, out var ch)) + channelId = ch; + db.BotState.Remove(chState); + } + var msgState = db.BotState.FirstOrDefault(i => i.Key == "bot-restart-msg"); + if (msgState != null) + { + restartMsg = msgState.Value; + db.BotState.Remove(msgState); + } + await db.SaveChangesAsync().ConfigureAwait(false); } - if (!Config.InMemorySettings.ContainsKey("shutdown")) - Sudo.Bot.Restart(InvalidChannelId, null); + if (string.IsNullOrEmpty(restartMsg)) + restartMsg = null; + + if (channelId.HasValue) + { + Config.Log.Info($"Found channelId {channelId}"); + DiscordChannel channel; + if (channelId == InvalidChannelId) + { + channel = await client.GetChannelAsync(Config.ThumbnailSpamId).ConfigureAwait(false); + await channel.SendMessageAsync(restartMsg ?? "Bot has suffered some catastrophic failure and was restarted").ConfigureAwait(false); + } + else + { + channel = await client.GetChannelAsync(channelId.Value).ConfigureAwait(false); + await channel.SendMessageAsync("Bot is up and running").ConfigureAwait(false); + } + } + else + { + Config.Log.Debug($"Args count: {args.Length}"); + var pArgs = args.Select(a => a == Config.Token ? "" : $"[{a}]"); + Config.Log.Debug("Args: " + string.Join(" ", pArgs)); + } + + Config.Log.Debug("Running RPCS3 update check thread"); + backgroundTasks = Task.WhenAll( + backgroundTasks, + NewBuildsMonitor.MonitorAsync(client), + Watchdog.Watch(client), + InviteWhitelistProvider.CleanupAsync(client), + UsernameValidationMonitor.MonitorAsync(client), + Psn.Check.MonitorFwUpdates(client, Config.Cts.Token), + Watchdog.SendMetrics(client), + Watchdog.CheckGCStats(), + mediaScreenshotMonitor.ProcessWorkQueue() + ); + + while (!Config.Cts.IsCancellationRequested) + { + if (client.Ping > 1000) + Config.Log.Warn($"High ping detected: {client.Ping}"); + await Task.Delay(TimeSpan.FromMinutes(1), Config.Cts.Token).ContinueWith(_ => {/* in case it was cancelled */}, TaskScheduler.Default).ConfigureAwait(false); + } + await backgroundTasks.ConfigureAwait(false); } + catch (Exception e) + { + if (!Config.InMemorySettings.ContainsKey("shutdown")) + Config.Log.Fatal(e, "Experienced catastrophic failure, attempting to restart..."); + } + finally + { + Config.TelemetryClient?.Flush(); + ShutdownCheck.Release(); + if (singleInstanceCheckThread.IsAlive) + singleInstanceCheckThread.Join(100); + } + if (!Config.InMemorySettings.ContainsKey("shutdown")) + Sudo.Bot.Restart(InvalidChannelId, null); } -} +} \ No newline at end of file diff --git a/CompatBot/ThumbScrapper/GameTdbScraper.cs b/CompatBot/ThumbScrapper/GameTdbScraper.cs index 8561eee3..0aeb3236 100644 --- a/CompatBot/ThumbScrapper/GameTdbScraper.cs +++ b/CompatBot/ThumbScrapper/GameTdbScraper.cs @@ -14,130 +14,129 @@ using CompatBot.Database.Providers; using CompatBot.EventHandlers; using Microsoft.EntityFrameworkCore; -namespace CompatBot.ThumbScrapper +namespace CompatBot.ThumbScrapper; + +internal static class GameTdbScraper { - internal static class GameTdbScraper + private static readonly HttpClient HttpClient = HttpClientFactory.Create(new CompressionMessageHandler()); + private static readonly Uri TitleDownloadLink = new("https://www.gametdb.com/ps3tdb.zip?LANG=EN"); + private static readonly Regex CoverArtLink = new(@"(?https?://art\.gametdb\.com/ps3/cover(?!full)[/\w\d]+\.jpg(\?\d+)?)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.ExplicitCapture); + //private static readonly List PreferredOrder = new List{"coverHQ", "coverM", "cover"}; + + public static async Task RunAsync(CancellationToken cancellationToken) { - private static readonly HttpClient HttpClient = HttpClientFactory.Create(new CompressionMessageHandler()); - private static readonly Uri TitleDownloadLink = new("https://www.gametdb.com/ps3tdb.zip?LANG=EN"); - private static readonly Regex CoverArtLink = new(@"(?https?://art\.gametdb\.com/ps3/cover(?!full)[/\w\d]+\.jpg(\?\d+)?)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.ExplicitCapture); - //private static readonly List PreferredOrder = new List{"coverHQ", "coverM", "cover"}; - - public static async Task RunAsync(CancellationToken cancellationToken) + do { - do - { - if (cancellationToken.IsCancellationRequested) - break; + if (cancellationToken.IsCancellationRequested) + break; - try - { - await UpdateGameTitlesAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - PrintError(e); - } - await Task.Delay(TimeSpan.FromDays(1), cancellationToken).ConfigureAwait(false); - } while (!cancellationToken.IsCancellationRequested); - } - - public static async Task GetThumbAsync(string productCode) - { try { - var html = await HttpClient.GetStringAsync("https://www.gametdb.com/PS3/" + productCode).ConfigureAwait(false); - var coverLinks = CoverArtLink.Matches(html).Select(m => m.Groups["cover_link"].Value).Distinct().Where(l => l.Contains(productCode, StringComparison.InvariantCultureIgnoreCase)).ToList(); - return coverLinks.FirstOrDefault(l => l.Contains("coverHQ", StringComparison.InvariantCultureIgnoreCase)) ?? - coverLinks.FirstOrDefault(l => l.Contains("coverM", StringComparison.InvariantCultureIgnoreCase)) ?? - coverLinks.FirstOrDefault(); + await UpdateGameTitlesAsync(cancellationToken).ConfigureAwait(false); } catch (Exception e) { - if (e is HttpRequestException hre && hre.Message.Contains("404")) - return null; - PrintError(e); } - return null; - } + await Task.Delay(TimeSpan.FromDays(1), cancellationToken).ConfigureAwait(false); + } while (!cancellationToken.IsCancellationRequested); + } - private static async Task UpdateGameTitlesAsync(CancellationToken cancellationToken) + public static async Task GetThumbAsync(string productCode) + { + try { - var container = Path.GetFileName(TitleDownloadLink.AbsolutePath); - try + var html = await HttpClient.GetStringAsync("https://www.gametdb.com/PS3/" + productCode).ConfigureAwait(false); + var coverLinks = CoverArtLink.Matches(html).Select(m => m.Groups["cover_link"].Value).Distinct().Where(l => l.Contains(productCode, StringComparison.InvariantCultureIgnoreCase)).ToList(); + return coverLinks.FirstOrDefault(l => l.Contains("coverHQ", StringComparison.InvariantCultureIgnoreCase)) ?? + coverLinks.FirstOrDefault(l => l.Contains("coverM", StringComparison.InvariantCultureIgnoreCase)) ?? + coverLinks.FirstOrDefault(); + } + catch (Exception e) + { + if (e is HttpRequestException hre && hre.Message.Contains("404")) + return null; + + PrintError(e); + } + return null; + } + + private static async Task UpdateGameTitlesAsync(CancellationToken cancellationToken) + { + var container = Path.GetFileName(TitleDownloadLink.AbsolutePath); + try + { + if (ScrapeStateProvider.IsFresh(container)) + return; + + Config.Log.Debug("Scraping GameTDB for game titles..."); + await using var fileStream = new FileStream(Path.GetTempFileName(), FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 16384, FileOptions.Asynchronous | FileOptions.RandomAccess | FileOptions.DeleteOnClose); + await using (var downloadStream = await HttpClient.GetStreamAsync(TitleDownloadLink, cancellationToken).ConfigureAwait(false)) + await downloadStream.CopyToAsync(fileStream, 16384, cancellationToken).ConfigureAwait(false); + fileStream.Seek(0, SeekOrigin.Begin); + using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read); + var logEntry = zipArchive.Entries.FirstOrDefault(e => e.Name.EndsWith(".xml", StringComparison.InvariantCultureIgnoreCase)); + if (logEntry == null) + throw new InvalidOperationException("No zip entries that match the .xml criteria"); + + await using var zipStream = logEntry.Open(); + using var xmlReader = XmlReader.Create(zipStream, new XmlReaderSettings { Async = true }); + xmlReader.ReadToFollowing("PS3TDB"); + var version = xmlReader.GetAttribute("version"); + if (!DateTime.TryParseExact(version, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp)) + return; + + if (ScrapeStateProvider.IsFresh("PS3TDB", timestamp)) { - if (ScrapeStateProvider.IsFresh(container)) - return; - - Config.Log.Debug("Scraping GameTDB for game titles..."); - await using var fileStream = new FileStream(Path.GetTempFileName(), FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 16384, FileOptions.Asynchronous | FileOptions.RandomAccess | FileOptions.DeleteOnClose); - await using (var downloadStream = await HttpClient.GetStreamAsync(TitleDownloadLink, cancellationToken).ConfigureAwait(false)) - await downloadStream.CopyToAsync(fileStream, 16384, cancellationToken).ConfigureAwait(false); - fileStream.Seek(0, SeekOrigin.Begin); - using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read); - var logEntry = zipArchive.Entries.FirstOrDefault(e => e.Name.EndsWith(".xml", StringComparison.InvariantCultureIgnoreCase)); - if (logEntry == null) - throw new InvalidOperationException("No zip entries that match the .xml criteria"); - - await using var zipStream = logEntry.Open(); - using var xmlReader = XmlReader.Create(zipStream, new XmlReaderSettings { Async = true }); - xmlReader.ReadToFollowing("PS3TDB"); - var version = xmlReader.GetAttribute("version"); - if (!DateTime.TryParseExact(version, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp)) - return; - - if (ScrapeStateProvider.IsFresh("PS3TDB", timestamp)) - { - await ScrapeStateProvider.SetLastRunTimestampAsync("PS3TDB").ConfigureAwait(false); - return; - } - - while (!cancellationToken.IsCancellationRequested && xmlReader.ReadToFollowing("game")) - { - if (!xmlReader.ReadToFollowing("id")) - continue; - - var productId = (await xmlReader.ReadElementContentAsStringAsync().ConfigureAwait(false)).ToUpperInvariant(); - if (!ProductCodeLookup.ProductCode.IsMatch(productId)) - continue; - - string? title = null; - if (xmlReader.ReadToFollowing("locale") && xmlReader.ReadToFollowing("title")) - title = await xmlReader.ReadElementContentAsStringAsync().ConfigureAwait(false); - - if (string.IsNullOrEmpty(title)) - continue; - - await using var db = new ThumbnailDb(); - var item = await db.Thumbnail.FirstOrDefaultAsync(t => t.ProductCode == productId, cancellationToken).ConfigureAwait(false); - if (item is null) - { - await db.Thumbnail.AddAsync(new Thumbnail {ProductCode = productId, Name = title}, cancellationToken).ConfigureAwait(false); - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - else if (item.Name != title && item.Timestamp == 0) - { - item.Name = title; - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - } await ScrapeStateProvider.SetLastRunTimestampAsync("PS3TDB").ConfigureAwait(false); - await ScrapeStateProvider.SetLastRunTimestampAsync(container).ConfigureAwait(false); + return; } - catch (Exception e) - { - PrintError(e); - } - finally - { - Config.Log.Debug("Finished scraping GameTDB for game titles"); - } - } - private static void PrintError(Exception e) + while (!cancellationToken.IsCancellationRequested && xmlReader.ReadToFollowing("game")) + { + if (!xmlReader.ReadToFollowing("id")) + continue; + + var productId = (await xmlReader.ReadElementContentAsStringAsync().ConfigureAwait(false)).ToUpperInvariant(); + if (!ProductCodeLookup.ProductCode.IsMatch(productId)) + continue; + + string? title = null; + if (xmlReader.ReadToFollowing("locale") && xmlReader.ReadToFollowing("title")) + title = await xmlReader.ReadElementContentAsStringAsync().ConfigureAwait(false); + + if (string.IsNullOrEmpty(title)) + continue; + + await using var db = new ThumbnailDb(); + var item = await db.Thumbnail.FirstOrDefaultAsync(t => t.ProductCode == productId, cancellationToken).ConfigureAwait(false); + if (item is null) + { + await db.Thumbnail.AddAsync(new Thumbnail {ProductCode = productId, Name = title}, cancellationToken).ConfigureAwait(false); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + else if (item.Name != title && item.Timestamp == 0) + { + item.Name = title; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + await ScrapeStateProvider.SetLastRunTimestampAsync("PS3TDB").ConfigureAwait(false); + await ScrapeStateProvider.SetLastRunTimestampAsync(container).ConfigureAwait(false); + } + catch (Exception e) { - Config.Log.Error(e, "Error scraping titles from GameTDB"); + PrintError(e); + } + finally + { + Config.Log.Debug("Finished scraping GameTDB for game titles"); } } -} + + private static void PrintError(Exception e) + { + Config.Log.Error(e, "Error scraping titles from GameTDB"); + } +} \ No newline at end of file diff --git a/CompatBot/ThumbScrapper/PsnScraper.cs b/CompatBot/ThumbScrapper/PsnScraper.cs index b9d01944..2521f2f1 100644 --- a/CompatBot/ThumbScrapper/PsnScraper.cs +++ b/CompatBot/ThumbScrapper/PsnScraper.cs @@ -12,117 +12,54 @@ using DSharpPlus.CommandsNext; using PsnClient.POCOs; using PsnClient.Utils; -namespace CompatBot.ThumbScrapper +namespace CompatBot.ThumbScrapper; + +internal sealed class PsnScraper { - internal sealed class PsnScraper + private static readonly PsnClient.Client Client = new(); + public static readonly Regex ContentIdMatcher = new(@"(?(?(?\w\w)(?\d{4}))-(?(?\w{4})(?\d{5}))_(?\d\d)-(?