use file-scoped namespaces to reduce nesting

some formatting might be fucked
This commit is contained in:
13xforever 2022-06-30 00:59:46 +05:00
parent a5d780f03d
commit 92751ba6e9
223 changed files with 23740 additions and 23975 deletions

View File

@ -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<IClient>();
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<IClient>();
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<BuildInfo?> GetPrBuildInfoAsync(string? commit, DateTime? oldestTimestamp, int pr, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(commit))
return null;
public static async Task<BuildInfo?> 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<ProjectBuildStats> 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<ProjectBuildStats> 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);
}

View File

@ -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; }
}

View File

@ -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),
};
}

View File

@ -5,94 +5,93 @@ using System.Net.Http.Headers;
using Microsoft.IO;
using NLog;
namespace CompatApiClient
namespace CompatApiClient;
using ReturnCodeType = Dictionary<int, (bool displayResults, bool overrideAll, bool displayFooter, string info)>;
public static class ApiConfig
{
using ReturnCodeType = Dictionary<int, (bool displayResults, bool overrideAll, bool displayFooter, string info)>;
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<int> ResultAmount = new(){25, 50, 100, 200};
public static readonly Dictionary<char, string[]> Directions = new()
{
{'a', new []{"a", "asc", "ascending"}},
{'d', new []{"d", "desc", "descending"} },
};
public static readonly Dictionary<string, int> Statuses = new()
{
{"all", 0 },
{"playable", 1 },
{"ingame", 2 },
{"intro", 3 },
{"loadable", 4 },
{"nothing", 5 },
};
public static readonly Dictionary<string, int> SortTypes = new()
{
{"id", 1 },
{"title", 2 },
{"status", 3 },
{"date", 4 },
};
public static readonly Dictionary<char, string[]> ReleaseTypes = new()
{
{'b', new[] {"b", "d", "disc", "disk", "bluray", "blu-ray"}},
{'n', new[] {"n", "p", "PSN"}},
};
public static readonly Dictionary<string, char> ReverseDirections;
public static readonly Dictionary<string, char> ReverseReleaseTypes;
private static Dictionary<TV, TK> Reverse<TK, TV>(this Dictionary<TK, TV[]> dic, IEqualityComparer<TV> 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<int> ResultAmount = new(){25, 50, 100, 200};
public static readonly Dictionary<char, string[]> Directions = new()
{
{'a', new []{"a", "asc", "ascending"}},
{'d', new []{"d", "desc", "descending"} },
};
public static readonly Dictionary<string, int> Statuses = new()
{
{"all", 0 },
{"playable", 1 },
{"ingame", 2 },
{"intro", 3 },
{"loadable", 4 },
{"nothing", 5 },
};
public static readonly Dictionary<string, int> SortTypes = new()
{
{"id", 1 },
{"title", 2 },
{"status", 3 },
{"date", 4 },
};
public static readonly Dictionary<char, string[]> ReleaseTypes = new()
{
{'b', new[] {"b", "d", "disc", "disk", "bluray", "blu-ray"}},
{'n', new[] {"n", "p", "PSN"}},
};
public static readonly Dictionary<string, char> ReverseDirections;
public static readonly Dictionary<string, char> ReverseReleaseTypes;
private static Dictionary<TV, TK> Reverse<TK, TV>(this Dictionary<TK, TV[]> dic, IEqualityComparer<TV> 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<string, char>();
ReverseReleaseTypes = new Dictionary<string, char>();
}
Log.Fatal(e);
ReverseDirections = new Dictionary<string, char>();
ReverseReleaseTypes = new Dictionary<string, char>();
}
}
}

View File

@ -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<CompatResult?> GetCompatResultAsync(RequestBuilder requestBuilder, CancellationToken cancellationToken)
//todo: cache results
public async Task<CompatResult?> 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<CompatResult>(jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null)
{
await response.Content.LoadIntoBufferAsync().ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<CompatResult>(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<CompatResult?> 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<CompatResult?> 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<CompatResult>(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<CompatResult>(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<UpdateInfo?> 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<UpdateInfo?> 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<UpdateInfo>(jsonOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
ConsoleLogger.PrintError(e, response, false);
}
return await response.Content.ReadFromJsonAsync<UpdateInfo>(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();
}
}

View File

@ -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;
}
}

View File

@ -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<ICompressor> 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<ICompressor> 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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
protected override async Task<HttpResponseMessage> 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;
}
}

View File

@ -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<long> 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<long> 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<long> 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<long> 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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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<long> CompressAsync(Stream source, Stream destination);
Task<long> DecompressAsync(Stream source, Stream destination);
}
string EncodingType { get; }
Task<long> CompressAsync(Stream source, Stream destination);
Task<long> DecompressAsync(Stream source, Stream destination);
}

View File

@ -2,25 +2,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CompatApiClient
{
public sealed class CompatApiCommitHashConverter : JsonConverter<string>
{
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<string>
{
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);
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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<string, TitleInfo> 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<string, TitleInfo> 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
}
#nullable restore

View File

@ -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
}
#nullable restore

View File

@ -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<string, string?>
{
Search = search;
return this;
}
public Uri Build(bool apiCall = true)
{
var parameters = new Dictionary<string, string?>
{
{"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);
}
}

View File

@ -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 { }
}
}
}

View File

@ -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<long> data)
{
public static long Mean(this IEnumerable<long> 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<long> data)
return (long)(sum / itemCount);
}
public static double StdDev(this IEnumerable<long> 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));
}
}

View File

@ -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<KeyValuePair<string, string>> 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<KeyValuePair<string, string?>> 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<KeyValuePair<string, string>> 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<KeyValuePair<string, string?>> 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);
}
}
}

View File

@ -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";
}
}

View File

@ -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<Octokit.PullRequest?> 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<Octokit.Issue?> 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<IReadOnlyList<Octokit.PullRequest>?> GetOpenPrsAsync(CancellationToken cancellationToken) => GetPrsWithStatusAsync(new Octokit.PullRequestRequest
{
State = Octokit.ItemStateFilter.Open
}, cancellationToken);
public Task<IReadOnlyList<Octokit.PullRequest>?> GetClosedPrsAsync(CancellationToken cancellationToken) => GetPrsWithStatusAsync(new Octokit.PullRequestRequest
{
State = Octokit.ItemStateFilter.Closed,
SortProperty = Octokit.PullRequestSort.Updated,
SortDirection = Octokit.SortDirection.Descending
}, cancellationToken);
private async Task<IReadOnlyList<Octokit.PullRequest>?> 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<Octokit.PullRequest>? 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<Octokit.PullRequest?> 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<Octokit.Issue?> 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<IReadOnlyList<Octokit.PullRequest>?> GetOpenPrsAsync(CancellationToken cancellationToken) => GetPrsWithStatusAsync(new Octokit.PullRequestRequest
{
State = Octokit.ItemStateFilter.Open
}, cancellationToken);
public Task<IReadOnlyList<Octokit.PullRequest>?> GetClosedPrsAsync(CancellationToken cancellationToken) => GetPrsWithStatusAsync(new Octokit.PullRequestRequest
{
State = Octokit.ItemStateFilter.Closed,
SortProperty = Octokit.PullRequestSort.Updated,
SortDirection = Octokit.SortDirection.Descending
}, cancellationToken);
private async Task<IReadOnlyList<Octokit.PullRequest>?> 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<Octokit.PullRequest>? 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}");
}
}

View File

@ -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/(?<filename>\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/(?<filename>\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<SearchResult?> SearchAsync(string query, CancellationToken cancellationToken)
public async Task<SearchResult?> 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<List<Ird>> DownloadAsync(string productCode, string localCachePath, CancellationToken cancellationToken)
catch (Exception e)
{
var result = new List<Ird>();
ApiConfig.Log.Error(e);
return null;
}
}
public async Task<List<Ird>> DownloadAsync(string productCode, string localCachePath, CancellationToken cancellationToken)
{
var result = new List<Ird>();
try
{
// first we search local cache and try to load whatever data we can
var localCacheItems = new List<string>();
try
{
// first we search local cache and try to load whatever data we can
var localCacheItems = new List<string>();
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<string>();
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("</span>", 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<string>();
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("</span>", StringComparison.Ordinal);
var result = html[(idx + 7)..].Trim();
if (string.IsNullOrEmpty(result))
return null;
return result;
}
}

View File

@ -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<byte[]> RegionMd5Checksums = null!; // 16 each
public int FileCount;
public List<IrdFile> 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 <v9
public int Uid;
public uint Crc32;
}
namespace IrdLibraryClient.IrdFormat;
public sealed class IrdFile
{
internal IrdFile() {}
public sealed class Ird
{
internal Ird(){}
public long Offset;
public byte[] Md5Checksum = null!;
}
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<byte[]> RegionMd5Checksums = null!; // 16 each
public int FileCount;
public List<IrdFile> 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 <v9
public int Uid;
public uint Crc32;
}
public sealed class IrdFile
{
internal IrdFile() {}
public long Offset;
public byte[] Md5Checksum = null!;
}

View File

@ -6,74 +6,73 @@ using System.Text;
using CompatApiClient;
using Force.Crc32;
namespace IrdLibraryClient.IrdFormat
namespace IrdLibraryClient.IrdFormat;
public static class IrdParser
{
public static class IrdParser
public static Ird Parse(byte[] content)
{
public static Ird Parse(byte[] content)
if (content == null)
throw new ArgumentNullException(nameof(content));
if (content.Length < 200)
throw new ArgumentException("Data is too small to be a valid IRD structure", nameof(content));
if (BitConverter.ToInt32(content, 0) != Ird.Magic)
{
if (content == null)
throw new ArgumentNullException(nameof(content));
if (content.Length < 200)
throw new ArgumentException("Data is too small to be a valid IRD structure", nameof(content));
if (BitConverter.ToInt32(content, 0) != Ird.Magic)
{
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<byte[]>(result.RegionCount);
for (var i = 0; i < result.RegionCount; i++)
result.RegionMd5Checksums.Add(reader.ReadBytes(16));
result.FileCount = reader.ReadInt32();
result.Files = new List<IrdFile>(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<byte[]>(result.RegionCount);
for (var i = 0; i < result.RegionCount; i++)
result.RegionMd5Checksums.Add(reader.ReadBytes(16));
result.FileCount = reader.ReadInt32();
result.Files = new List<IrdFile>(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;
}
}
}

View File

@ -5,28 +5,27 @@ using System.Linq;
using CompatApiClient;
using DiscUtils.Iso9660;
namespace IrdLibraryClient.IrdFormat
{
public static class IsoHeaderParser
{
public static List<string> 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<string> 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();
}
}

View File

@ -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<SearchResultItem>? 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<SearchResultItem>? 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;
}

View File

@ -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*""(?<security_token>.+)""", 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*""(?<direct_link>https?://download\d+\.mediafire\.com/.+)""");
//var optSecurityToken = "1605819132.376f3d84695f46daa7b69ee67fbc5edb0a00843a8b2d5ac7d3d1b1ad8a4212b0";
//private static readonly Regex SecurityTokenRegex = new(@"(var\s+optSecurityToken|name=""security"" value)\s*=\s*""(?<security_token>.+)""", 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*""(?<direct_link>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<LinksResult?> GetWebLinkAsync(string quickKey, CancellationToken cancellationToken)
public async Task<LinksResult?> 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<LinksResult>(jsonOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
ConsoleLogger.PrintError(e, response);
}
await response.Content.LoadIntoBufferAsync().ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<LinksResult>(jsonOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
ApiConfig.Log.Error(e);
ConsoleLogger.PrintError(e, response);
}
return null;
}
public async Task<Uri?> GetDirectDownloadLinkAsync(Uri webLink, CancellationToken cancellationToken)
catch (Exception e)
{
try
ApiConfig.Log.Error(e);
}
return null;
}
public async Task<Uri?> 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;
}
}

View File

@ -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
}
#nullable restore

View File

@ -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<Uri?> 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<DriveItemMeta?> 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<DriveItemMeta>(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<Uri?> 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<DriveItemMeta?> 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<DriveItemMeta>(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;
}
}

View File

@ -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;
}

View File

@ -7,104 +7,103 @@ using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using CompatApiClient;
namespace PsnClient
{
public class CustomTlsCertificatesHandler: HttpClientHandler
{
private readonly Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>? defaultCertHandler;
private static readonly X509CertificateCollection CustomCaCollection = new X509Certificate2Collection();
private static readonly ConcurrentDictionary<string, bool> ValidationCache = new(1, 5);
namespace PsnClient;
static CustomTlsCertificatesHandler()
public class CustomTlsCertificatesHandler: HttpClientHandler
{
private readonly Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>? defaultCertHandler;
private static readonly X509CertificateCollection CustomCaCollection = new X509Certificate2Collection();
private static readonly ConcurrentDictionary<string, bool> 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;
}
}

View File

@ -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"
}

View File

@ -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

View File

@ -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
}
public class RelationshipsLegacySkus
{
public RelationshipsChildrenItem[] Data;
}
#nullable restore

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(@"(?<id>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(@"(?<id>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=(?<dest>\d+);ImageVersion=(?<image>[0-9a-f]+);SystemSoftwareVersion=(?<version>\d+\.\d+);CDN=(?<url>http[^;]+);CDN_Timeout=(?<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=(?<dest>\d+);ImageVersion=(?<image>[0-9a-f]+);SystemSoftwareVersion=(?<version>\d+\.\d+);CDN=(?<url>http[^;]+);CDN_Timeout=(?<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<Stores?> GetStoresAsync(string locale, CancellationToken cancellationToken)
public async Task<Stores?> 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<Stores>(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<List<string>?> 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<string>(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<string>();
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<Stores>(snakeJson, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
@ -143,306 +83,365 @@ namespace PsnClient
return null;
}
}
public async Task<StoreNavigation?> GetStoreNavigationAsync(string locale, string containerId, CancellationToken cancellationToken)
catch (Exception e)
{
try
ApiConfig.Log.Error(e);
return null;
}
}
public async Task<List<string>?> 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<string>(0);
}
using (response)
try
{
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await response.Content.LoadIntoBufferAsync().ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<StoreNavigation>(dashedJson, cancellationToken).ConfigureAwait(false);
var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var matches = ContainerIdLink.Matches(html);
var result = new List<string>();
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<Container?> GetGameContainerAsync(string locale, string containerId, int start, int take, Dictionary<string, string> 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<Container>(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<Container?> ResolveContentAsync(string locale, string contentId, int depth, CancellationToken cancellationToken)
public async Task<StoreNavigation?> 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<Container>(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<TitlePatch>(xmlFormatters, cancellationToken).ConfigureAwait(false);
ResponseCache.Set(productId, patchInfo, ResponseCacheDuration);
return (patchInfo, xml);
return await response.Content.ReadFromJsonAsync<StoreNavigation>(dashedJson, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
ConsoleLogger.PrintError(e, response);
throw;
}
}
public async Task<TitleMeta?> 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<TitleMeta>(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<Container?> 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<Container>(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<List<FirmwareInfo>> GetHighestFwVersionAsync(CancellationToken cancellationToken)
{
var tasks = new List<Task<FirmwareInfo?>>(KnownFwLocales.Length);
foreach (var fwLocale in KnownFwLocales)
tasks.Add(GetFwVersionAsync(fwLocale, cancellationToken));
var allVersions = new List<FirmwareInfo>(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<FirmwareInfo>(0);
var maxFw = allVersions.First();
var result = allVersions.Where(fwi => fwi.Version == maxFw.Version).ToList();
return result;
}
private async Task<string> 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<string, string>
{
["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<FirmwareInfo?> 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<Container?> GetGameContainerAsync(string locale, string containerId, int start, int take, Dictionary<string, string> 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<Container>(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<Container?> 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<Container>(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<TitlePatch>(xmlFormatters, cancellationToken).ConfigureAwait(false);
ResponseCache.Set(productId, patchInfo, ResponseCacheDuration);
return (patchInfo, xml);
}
catch (Exception e)
{
ConsoleLogger.PrintError(e, response);
throw;
}
}
public async Task<TitleMeta?> 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<TitleMeta>(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<Container?> 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<Container>(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<List<FirmwareInfo>> GetHighestFwVersionAsync(CancellationToken cancellationToken)
{
var tasks = new List<Task<FirmwareInfo?>>(KnownFwLocales.Length);
foreach (var fwLocale in KnownFwLocales)
tasks.Add(GetFwVersionAsync(fwLocale, cancellationToken));
var allVersions = new List<FirmwareInfo>(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<FirmwareInfo>(0);
var maxFw = allVersions.First();
var result = allVersions.Where(fwi => fwi.Version == maxFw.Version).ToList();
return result;
}
private async Task<string> 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<string, string>
{
["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<FirmwareInfo?> 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;
}
}
}

View File

@ -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]);
}
}
}

View File

@ -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<byte>();
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<byte>();
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();
}
}

View File

@ -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<ResourceInfo?> GetResourceInfoAsync(string shareKey, CancellationToken cancellationToken)
=> GetResourceInfoAsync(new Uri($"https://yadi.sk/d/{shareKey}"), cancellationToken);
public Task<ResourceInfo?> GetResourceInfoAsync(string shareKey, CancellationToken cancellationToken)
=> GetResourceInfoAsync(new Uri($"https://yadi.sk/d/{shareKey}"), cancellationToken);
public async Task<ResourceInfo?> GetResourceInfoAsync(Uri publicUri, CancellationToken cancellationToken)
public async Task<ResourceInfo?> 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<ResourceInfo>(jsonOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
ConsoleLogger.PrintError(e, response);
}
await response.Content.LoadIntoBufferAsync().ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<ResourceInfo>(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;
}
}

View File

@ -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; //<direct download url>
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; //<direct download url>
public string MediaType; //compressed
public string Md5;
public string Sha256;
public long? Revision;
}
#nullable restore
}
#nullable restore

View File

@ -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<bool> IsAllowed(CommandContext ctx, bool help);
public DiscordEmoji? ReactOnSuccess { get; }
public DiscordEmoji? ReactOnFailure { get; }
public CheckBaseAttributeWithReactions(DiscordEmoji? reactOnSuccess = null, DiscordEmoji? reactOnFailure = null)
{
protected abstract Task<bool> 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<bool> 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<bool> 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;
}
}

View File

@ -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<bool> ExecuteCheckAsync(CommandContext ctx, bool help)
{
public override async Task<bool> 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;
}
}

View File

@ -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<bool> ExecuteCheckAsync(CommandContext ctx, bool help)
{
public override async Task<bool> 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);
}
}

View File

@ -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<bool> ExecuteCheckAsync(CommandContext ctx, bool help)
{
public override async Task<bool> 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);
}
}

View File

@ -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<bool> 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<bool> IsAllowed(CommandContext ctx, bool help)
{
return Task.FromResult(ModProvider.IsMod(ctx.User.Id));
}
}
}

View File

@ -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<bool> 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<bool> IsAllowed(CommandContext ctx, bool help)
=> Task.FromResult(ctx.User.IsModerator(ctx.Client, ctx.Guild));
}

View File

@ -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<byte[]> Poster = new(() =>
{
private const string Source = "https://cdn.discordapp.com/attachments/417347469521715210/534798232858001418/24qx11.jpg";
private static readonly Lazy<byte[]> 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<bool> ExecuteCheckAsync(CommandContext ctx, bool help)
{
if (ctx.Channel.IsPrivate || help)
return true;
public override async Task<bool> 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;
}
}

View File

@ -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<bool> ExecuteCheckAsync(CommandContext ctx, bool help)
{
public override Task<bool> ExecuteCheckAsync(CommandContext ctx, bool help)
{
return Task.FromResult(ctx.Channel.Name != "media");
}
return Task.FromResult(ctx.Channel.Name != "media");
}
}

View File

@ -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<bool> 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<bool> IsAllowed(CommandContext ctx, bool help)
{
return Task.FromResult(ctx.User.IsWhitelisted(ctx.Client, ctx.Guild) || ctx.User.IsSupporter(ctx.Client, ctx.Guild));
}
}

View File

@ -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<bool> 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<bool> IsAllowed(CommandContext ctx, bool help)
{
return Task.FromResult(ctx.User.IsWhitelisted(ctx.Client, ctx.Guild));
}
}

View File

@ -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;
}
}

View File

@ -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<TriggersTyping>().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<TriggersTyping>().FirstOrDefault() is TriggersTyping a && a.ExecuteCheck(ctx);
}

View File

@ -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 <https://mathparser.org/mxparser-math-collection/>");
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 <https://mathparser.org/mxparser-math-collection/>");
}

View File

@ -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>();
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<string>();
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<string>();
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<string>();
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>();
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<string>();
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<string>();
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<string>();
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 = {"🙀", "😿", "😾",};
}

View File

@ -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<Command>)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<Command>)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);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<DiscordChannel>
{
internal sealed class TextOnlyDiscordChannelConverter : IArgumentConverter<DiscordChannel>
{
private static Regex ChannelRegex { get; } = new(@"^<#(\d+)>$", RegexOptions.ECMAScript | RegexOptions.Compiled);
private static Regex ChannelRegex { get; } = new(@"^<#(\d+)>$", RegexOptions.ECMAScript | RegexOptions.Compiled);
Task<Optional<DiscordChannel>> IArgumentConverter<DiscordChannel>.ConvertAsync(string value, CommandContext ctx)
=> ConvertAsync(value, ctx);
Task<Optional<DiscordChannel>> IArgumentConverter<DiscordChannel>.ConvertAsync(string value, CommandContext ctx)
=> ConvertAsync(value, ctx);
public static async Task<Optional<DiscordChannel>> ConvertAsync(string value, CommandContext ctx)
public static async Task<Optional<DiscordChannel>> ConvertAsync(string value, CommandContext ctx)
{
var guildList = new List<DiscordGuild>(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<DiscordGuild>(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<DiscordChannel>() : 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<DiscordChannel>() : 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<DiscordChannel>() : Optional.FromValue(chn.Value);
).FirstOrDefault(xc => xc.Key == cid && xc.Value?.Type == ChannelType.Text);
var ret = result.Value == null ? Optional.FromNoValue<DiscordChannel>() : 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<DiscordChannel>() : 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<DiscordChannel>() : Optional.FromValue(chn.Value);
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);
}

File diff suppressed because it is too large Load Diff

View File

@ -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<DiscordUser>)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<DiscordUser>)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<bool> 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<bool> 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);
}
}
}

View File

@ -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<DiscordGuild> guilds;
if (ctx.Guild == null)
{
guilds = ctx.Client.Guilds?.Values.ToList() ?? new List<DiscordGuild>(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<DiscordGuild> guilds;
if (ctx.Guild == null)
{
guilds = ctx.Client.Guilds?.Values.ToList() ?? new List<DiscordGuild>(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);
}
}

View File

@ -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);
}
}

View File

@ -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<int>();
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<int>();
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);
}
}

View File

@ -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);
}
}
}

View File

@ -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 = {"", "", "", "", "", "", "", "", "", "",};
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 = {"", "", "", "", "", "", "", "", "", "",};
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<string, int>();
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<string, int>();
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<DiscordMember> 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<DiscordMember>(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<DiscordMember> 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<DiscordMember>(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);
}
}
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<DiscordEmbedBuilder> 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<DiscordEmbedBuilder> 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<FirmwareInfo>? 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<FirmwareInfo>? 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);
}
}
}
}
}

View File

@ -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);
}
}

View File

@ -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()));
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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)? (?<major>\d+)\.(?<minor>\d+)\.(?<patch>\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, @"(?<upgraded>\d+) upgraded, (?<installed>\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)? (?<major>\d+)\.(?<minor>\d+)\.(?<patch>\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, @"(?<upgraded>\d+) upgraded, (?<installed>\d+) newly installed");
if (stdout.Contains("is already the newest version", StringComparison.InvariantCultureIgnoreCase))
return (false, stdout);
return (true, stdout);
}
}
}

View File

@ -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(@"^(?<cutout>(?<date>\d{4}-\d\d-\d\d[ T][0-9:\.]+Z?) - )", RegexOptions.ExplicitCapture | RegexOptions.Singleline);
private static readonly Regex Channel = new(@"(?<id><#\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(@"^(?<cutout>(?<date>\d{4}-\d\d-\d\d[ T][0-9:\.]+Z?) - )", RegexOptions.ExplicitCapture | RegexOptions.Singleline);
private static readonly Regex Channel = new(@"(?<id><#\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<string?> 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<string?> 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;
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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<string, string[]> 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<string, string[]> 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<VisualFeatureTypes?>
{
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<Color> 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<Rgba32>(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<Color>();
var tmpCp = new List<Color>();
var uniqueCp = new HashSet<Color>();
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?>
{
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<Color> 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<RectangleF>(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<Rgba32>(tmpStream);
var dominantColor = ColorGetter.GetDominentColor(b);
palette.Add(dominantColor);
}
var complementaryPalette = palette.Select(c => c.GetComplementary()).ToList();
var tmpP = new List<Color>();
var tmpCp = new List<Color>();
var uniqueCp = new HashSet<Color>();
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<RectangleF>(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<string> 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<DiscordAttachment> GetImageAttachments(DiscordMessage message)
=> message.Attachments.Where(a =>
internal static IEnumerable<string> 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<DiscordAttachment> 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<string> 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<string?> 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<string> 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<string?> 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;
}
}

View File

@ -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<DiscordUser>)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<Warning> 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<bool> 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<DiscordUser>)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<Warning> 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<bool> 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;
}
}
}
}

View File

@ -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<bool> 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<bool> 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<bool> 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<bool> 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<Warning> 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<Warning> 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);
}
}
}
}

View File

@ -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<string, string> 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<IMention>();
}
internal static readonly ILogger Log;
internal static readonly ILoggerFactory LoggerFactory;
internal static readonly ConcurrentDictionary<string, string> 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<IMention>();
}
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<UserSecretsIdAttribute>() 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<UserSecretsIdAttribute>() 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<ulong> Channels = new List<ulong>
{
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<ulong> OcrChannels = new HashSet<ulong>(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<string> RoleWhiteList = new HashSet<string>(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<string> RoleSmartList = new HashSet<string>(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<string> SupporterRoleList = new HashSet<string>(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<ulong> Channels = new List<ulong>
{
272875751773306881, // #media
319224795785068545,
}.AsReadOnly();
public static readonly IReadOnlyCollection<ulong> OcrChannels = new HashSet<ulong>(Channels)
{
272035812277878785, // #rpcs3
277227681836302338, // #help
272875751773306881, // #media
// test server
564846659109126244, // #media
534749301797158914, // private-spam
};
public static readonly IReadOnlyCollection<string> RoleWhiteList = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
{
"Administrator",
"Community Manager",
"Web Developer",
"Moderator",
"Lead Graphics Developer",
"Lead Core Developer",
"Developers",
"Affiliated",
};
public static readonly IReadOnlyCollection<string> RoleSmartList = new HashSet<string>(RoleWhiteList, StringComparer.InvariantCultureIgnoreCase)
{
"Testers",
"Helpers",
"Contributors",
};
public static readonly IReadOnlyCollection<string> SupporterRoleList = new HashSet<string>(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<BuildHttpClient>();
}
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<BuildHttpClient>();
}
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);
}
}
}

View File

@ -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> BotState { get; set; } = null!;
public DbSet<Moderator> Moderator { get; set; } = null!;
public DbSet<Piracystring> Piracystring { get; set; } = null!;
public DbSet<SuspiciousString> SuspiciousString { get; set; } = null!;
public DbSet<Warning> Warning { get; set; } = null!;
public DbSet<Explanation> Explanation { get; set; } = null!;
public DbSet<DisabledCommand> DisabledCommands { get; set; } = null!;
public DbSet<WhitelistedInvite> WhitelistedInvites { get; set; } = null!;
public DbSet<EventSchedule> EventSchedule { get; set; } = null!;
public DbSet<Stats> Stats { get; set; } = null!;
public DbSet<Kot> Kot { get; set; } = null!;
public DbSet<Doggo> Doggo { get; set; } = null!;
public DbSet<ForcedNickname> ForcedNicknames { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
public DbSet<BotState> BotState { get; set; } = null!;
public DbSet<Moderator> Moderator { get; set; } = null!;
public DbSet<Piracystring> Piracystring { get; set; } = null!;
public DbSet<SuspiciousString> SuspiciousString { get; set; } = null!;
public DbSet<Warning> Warning { get; set; } = null!;
public DbSet<Explanation> Explanation { get; set; } = null!;
public DbSet<DisabledCommand> DisabledCommands { get; set; } = null!;
public DbSet<WhitelistedInvite> WhitelistedInvites { get; set; } = null!;
public DbSet<EventSchedule> EventSchedule { get; set; } = null!;
public DbSet<Stats> Stats { get; set; } = null!;
public DbSet<Kot> Kot { get; set; } = null!;
public DbSet<Doggo> Doggo { get; set; } = null!;
public DbSet<ForcedNickname> 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<BotState>().HasIndex(m => m.Key).IsUnique().HasDatabaseName("bot_state_key");
modelBuilder.Entity<Moderator>().HasIndex(m => m.DiscordId).IsUnique().HasDatabaseName("moderator_discord_id");
modelBuilder.Entity<Piracystring>().Property(ps => ps.Context).HasDefaultValue(FilterContext.Chat | FilterContext.Log);
modelBuilder.Entity<Piracystring>().Property(ps => ps.Actions).HasDefaultValue(FilterAction.RemoveContent | FilterAction.IssueWarning | FilterAction.SendMessage);
modelBuilder.Entity<Piracystring>().HasIndex(ps => ps.String).HasDatabaseName("piracystring_string");
modelBuilder.Entity<SuspiciousString>().HasIndex(ss => ss.String).HasDatabaseName("suspicious_string_string");
modelBuilder.Entity<Warning>().HasIndex(w => w.DiscordId).HasDatabaseName("warning_discord_id");
modelBuilder.Entity<Explanation>().HasIndex(e => e.Keyword).IsUnique().HasDatabaseName("explanation_keyword");
modelBuilder.Entity<DisabledCommand>().HasIndex(c => c.Command).IsUnique().HasDatabaseName("disabled_command_command");
modelBuilder.Entity<WhitelistedInvite>().HasIndex(i => i.GuildId).IsUnique().HasDatabaseName("whitelisted_invite_guild_id");
modelBuilder.Entity<EventSchedule>().HasIndex(e => new {e.Year, e.EventName}).HasDatabaseName("event_schedule_year_event_name");
modelBuilder.Entity<Stats>().HasIndex(s => new { s.Category, s.Key }).IsUnique().HasDatabaseName("stats_category_key");
modelBuilder.Entity<Kot>().HasIndex(k => k.UserId).IsUnique().HasDatabaseName("kot_user_id");
modelBuilder.Entity<Doggo>().HasIndex(d => d.UserId).IsUnique().HasDatabaseName("doggo_user_id");
modelBuilder.Entity<ForcedNickname>().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<BotState>().HasIndex(m => m.Key).IsUnique().HasDatabaseName("bot_state_key");
modelBuilder.Entity<Moderator>().HasIndex(m => m.DiscordId).IsUnique().HasDatabaseName("moderator_discord_id");
modelBuilder.Entity<Piracystring>().Property(ps => ps.Context).HasDefaultValue(FilterContext.Chat | FilterContext.Log);
modelBuilder.Entity<Piracystring>().Property(ps => ps.Actions).HasDefaultValue(FilterAction.RemoveContent | FilterAction.IssueWarning | FilterAction.SendMessage);
modelBuilder.Entity<Piracystring>().HasIndex(ps => ps.String).HasDatabaseName("piracystring_string");
modelBuilder.Entity<SuspiciousString>().HasIndex(ss => ss.String).HasDatabaseName("suspicious_string_string");
modelBuilder.Entity<Warning>().HasIndex(w => w.DiscordId).HasDatabaseName("warning_discord_id");
modelBuilder.Entity<Explanation>().HasIndex(e => e.Keyword).IsUnique().HasDatabaseName("explanation_keyword");
modelBuilder.Entity<DisabledCommand>().HasIndex(c => c.Command).IsUnique().HasDatabaseName("disabled_command_command");
modelBuilder.Entity<WhitelistedInvite>().HasIndex(i => i.GuildId).IsUnique().HasDatabaseName("whitelisted_invite_guild_id");
modelBuilder.Entity<EventSchedule>().HasIndex(e => new {e.Year, e.EventName}).HasDatabaseName("event_schedule_year_event_name");
modelBuilder.Entity<Stats>().HasIndex(s => new { s.Category, s.Key }).IsUnique().HasDatabaseName("stats_category_key");
modelBuilder.Entity<Kot>().HasIndex(k => k.UserId).IsUnique().HasDatabaseName("kot_user_id");
modelBuilder.Entity<Doggo>().HasIndex(d => d.UserId).IsUnique().HasDatabaseName("doggo_user_id");
modelBuilder.Entity<ForcedNickname>().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!;
}

View File

@ -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<bool> UpgradeAsync(CancellationToken cancellationToken)
{
public static async Task<bool> 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<bool> UpgradeAsync(DbContext dbContext, CancellationToken cancellationToken)
private static async Task<bool> 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<bool> 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<bool> 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<string>();
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<string>();
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;
}
}
}

View File

@ -1,31 +1,30 @@
using System;
using Microsoft.EntityFrameworkCore;
namespace CompatBot.Database
{
internal static class NamingConventionConverter
{
public static void ConfigureMapping(this ModelBuilder modelBuilder, Func<string, string> 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<string, string> 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));
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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<string, List<string>> VulkanToDriver = new();
private static readonly Dictionary<string, string> OpenglToDriver = new();
private static readonly SemaphoreSlim SyncObj = new(1, 1);
public static async Task RefreshAsync()
{
private static readonly Dictionary<string, List<string>> VulkanToDriver = new();
private static readonly Dictionary<string, string> 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<string>();
if (string.IsNullOrEmpty(driverVer))
continue;
if (!VulkanToDriver.TryGetValue(vkVer, out var verList))
VulkanToDriver[vkVer] = verList = new List<string>();
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<string> 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<string> 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<string> 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<string> 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;
}
}
}

View File

@ -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<FilterContext, AhoCorasickDoubleArrayTrie<Piracystring>?> 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<Piracystring?> FindTriggerAsync(FilterContext ctx, string str)
{
private static Dictionary<FilterContext, AhoCorasickDoubleArrayTrie<Piracystring>?> 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<Piracystring?> 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<FilterContext, AhoCorasickDoubleArrayTrie<Piracystring>?>();
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<FilterContext, AhoCorasickDoubleArrayTrie<Piracystring>?>();
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<string, Piracystring>();
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<string, Piracystring>();
foreach (var ps in triggerList)
triggerDictionary[ps.String] = ps;
newFilters[ctx] = new(triggerDictionary, true);
}
}
filters = newFilters;
}
filters = newFilters;
}
public static async Task<bool> IsClean(DiscordClient client, DiscordMessage message)
{
if (message.Channel.IsPrivate)
return true;
public static async Task<bool> 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<FilterAction>();
if (trigger.Actions.HasFlag(FilterAction.RemoveContent) && !ignoreFlags.HasFlag(FilterAction.RemoveContent))
{
var severity = ReportSeverity.Low;
var completedActions = new List<FilterAction>();
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;
}

View File

@ -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<string> DisabledCommands = new(StringComparer.InvariantCultureIgnoreCase);
static DisabledCommandsProvider()
{
private static readonly HashSet<string> 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<string> 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<string> 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();
}
}
}

Some files were not shown because too many files have changed in this diff Show More