mirror of
https://github.com/RPCS3/discord-bot.git
synced 2024-11-26 19:50:36 +00:00
use file-scoped namespaces to reduce nesting
some formatting might be fucked
This commit is contained in:
parent
a5d780f03d
commit
92751ba6e9
@ -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);
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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),
|
||||
};
|
||||
}
|
@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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();
|
||||
}
|
@ -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
|
@ -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
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
@ -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}");
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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!;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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/>");
|
||||
}
|
@ -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 = {"🙀", "😿", "😾",};
|
||||
}
|
@ -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
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -6,133 +6,132 @@ using CompatBot.Commands.Attributes;
|
||||
using DSharpPlus.CommandsNext;
|
||||
using DSharpPlus.CommandsNext.Attributes;
|
||||
|
||||
namespace CompatBot.Commands
|
||||
namespace CompatBot.Commands;
|
||||
|
||||
[Group("minesweeper"), Aliases("msgen")]
|
||||
[LimitedToOfftopicChannel, Cooldown(1, 30, CooldownBucketType.Channel)]
|
||||
[Description("Generates a minesweeper field with specified parameters")]
|
||||
internal sealed class Minesweeper : BaseCommandModuleCustom
|
||||
{
|
||||
[Group("minesweeper"), Aliases("msgen")]
|
||||
[LimitedToOfftopicChannel, Cooldown(1, 30, CooldownBucketType.Channel)]
|
||||
[Description("Generates a minesweeper field with specified parameters")]
|
||||
internal sealed class Minesweeper : BaseCommandModuleCustom
|
||||
//private static readonly string[] Numbers = {"0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣",};
|
||||
private static readonly string[] Numbers = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",};
|
||||
private static readonly string[] Bombs = {"*", "◎"};
|
||||
private static readonly int MaxBombLength;
|
||||
|
||||
static Minesweeper()
|
||||
{
|
||||
//private static readonly string[] Numbers = {"0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣",};
|
||||
private static readonly string[] Numbers = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",};
|
||||
private static readonly string[] Bombs = {"*", "◎"};
|
||||
private static readonly int MaxBombLength;
|
||||
MaxBombLength = Bombs.Select(b => b.Length).Max();
|
||||
}
|
||||
|
||||
static Minesweeper()
|
||||
{
|
||||
MaxBombLength = Bombs.Select(b => b.Length).Max();
|
||||
}
|
||||
|
||||
private enum CellVal: byte
|
||||
{
|
||||
Zero = 0,
|
||||
One = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6,
|
||||
Seven = 7,
|
||||
Eight = 8,
|
||||
private enum CellVal: byte
|
||||
{
|
||||
Zero = 0,
|
||||
One = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6,
|
||||
Seven = 7,
|
||||
Eight = 8,
|
||||
|
||||
OpenZero = 100,
|
||||
OpenZero = 100,
|
||||
|
||||
Mine = 255,
|
||||
Mine = 255,
|
||||
}
|
||||
|
||||
[GroupCommand]
|
||||
public async Task Generate(CommandContext ctx,
|
||||
[Description("Width of the field")] int width = 14,
|
||||
[Description("Height of the field")] int height = 14,
|
||||
[Description("Number of mines")] int mineCount = 30)
|
||||
{
|
||||
if (width < 3 || height < 3 || mineCount < 1)
|
||||
{
|
||||
await ctx.Channel.SendMessageAsync("Invalid generation parameters").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
[GroupCommand]
|
||||
public async Task Generate(CommandContext ctx,
|
||||
[Description("Width of the field")] int width = 14,
|
||||
[Description("Height of the field")] int height = 14,
|
||||
[Description("Number of mines")] int mineCount = 30)
|
||||
var header = $"{mineCount}x💣\n";
|
||||
var footer = "If something is cut off, blame Discord";
|
||||
var maxMineCount = (width - 1) * (height - 1) * 2 / 3;
|
||||
if (mineCount > maxMineCount)
|
||||
{
|
||||
if (width < 3 || height < 3 || mineCount < 1)
|
||||
{
|
||||
await ctx.Channel.SendMessageAsync("Invalid generation parameters").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var header = $"{mineCount}x💣\n";
|
||||
var footer = "If something is cut off, blame Discord";
|
||||
var maxMineCount = (width - 1) * (height - 1) * 2 / 3;
|
||||
if (mineCount > maxMineCount)
|
||||
{
|
||||
await ctx.Channel.SendMessageAsync("Isn't this a bit too many mines 🤔").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (height > 98)
|
||||
{
|
||||
await ctx.Channel.SendMessageAsync("Too many lines for one message, Discord would truncate the result randomly").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var msgLen = (4 * width * height - 4) + (height - 1) + mineCount * MaxBombLength + (width * height - mineCount) * "0️⃣".Length + header.Length;
|
||||
if (width * height > 198 || msgLen > 2000) // for some reason discord would cut everything beyond 198 cells even if the content length is well within the limits
|
||||
{
|
||||
await ctx.Channel.SendMessageAsync("Requested field size is too large for one message").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var rng = new Random();
|
||||
var field = GenerateField(width, height, mineCount, rng);
|
||||
var result = new StringBuilder(msgLen).Append(header);
|
||||
var bomb = rng.NextDouble() > 0.9 ? Bombs[rng.Next(Bombs.Length)] : Bombs[0];
|
||||
var needOneOpenCell = true;
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var c = (CellVal)field[y, x] == CellVal.Mine ? bomb : Numbers[field[y, x]];
|
||||
if (needOneOpenCell && field[y, x] == 0)
|
||||
{
|
||||
result.Append(c);
|
||||
needOneOpenCell = false;
|
||||
}
|
||||
else
|
||||
result.Append("||").Append(c).Append("||");
|
||||
}
|
||||
result.Append('\n');
|
||||
}
|
||||
result.Append(footer);
|
||||
await ctx.Channel.SendMessageAsync(result.ToString()).ConfigureAwait(false);
|
||||
await ctx.Channel.SendMessageAsync("Isn't this a bit too many mines 🤔").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
private static byte[,] GenerateField(int width, int height, in int mineCount, in Random rng)
|
||||
if (height > 98)
|
||||
{
|
||||
var len = width * height;
|
||||
var cells = new byte[len];
|
||||
// put mines
|
||||
for (var i = 0; i < mineCount; i++)
|
||||
cells[i] = (byte)CellVal.Mine;
|
||||
await ctx.Channel.SendMessageAsync("Too many lines for one message, Discord would truncate the result randomly").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
//shuffle the board
|
||||
for (var i = 0; i < len - 1; i++)
|
||||
{
|
||||
var j = rng.Next(i, len);
|
||||
(cells[i], cells[j]) = (cells[j], cells[i]);
|
||||
}
|
||||
var result = new byte[height, width];
|
||||
Buffer.BlockCopy(cells, 0, result, 0, len);
|
||||
var msgLen = (4 * width * height - 4) + (height - 1) + mineCount * MaxBombLength + (width * height - mineCount) * "0️⃣".Length + header.Length;
|
||||
if (width * height > 198 || msgLen > 2000) // for some reason discord would cut everything beyond 198 cells even if the content length is well within the limits
|
||||
{
|
||||
await ctx.Channel.SendMessageAsync("Requested field size is too large for one message").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
//update mine indicators
|
||||
byte get(int x, int y) => x < 0 || x >= width || y < 0 || y >= height ? (byte)0 : result[y, x];
|
||||
|
||||
byte countMines(int x, int y)
|
||||
{
|
||||
byte c = 0;
|
||||
for (var yy = y - 1; yy <= y + 1; yy++)
|
||||
for (var xx = x - 1; xx <= x + 1; xx++)
|
||||
if ((CellVal)get(xx, yy) == CellVal.Mine)
|
||||
c++;
|
||||
return c;
|
||||
}
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
var rng = new Random();
|
||||
var field = GenerateField(width, height, mineCount, rng);
|
||||
var result = new StringBuilder(msgLen).Append(header);
|
||||
var bomb = rng.NextDouble() > 0.9 ? Bombs[rng.Next(Bombs.Length)] : Bombs[0];
|
||||
var needOneOpenCell = true;
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
if ((CellVal)result[y, x] != CellVal.Mine)
|
||||
result[y, x] = countMines(x, y);
|
||||
return result;
|
||||
{
|
||||
var c = (CellVal)field[y, x] == CellVal.Mine ? bomb : Numbers[field[y, x]];
|
||||
if (needOneOpenCell && field[y, x] == 0)
|
||||
{
|
||||
result.Append(c);
|
||||
needOneOpenCell = false;
|
||||
}
|
||||
else
|
||||
result.Append("||").Append(c).Append("||");
|
||||
}
|
||||
result.Append('\n');
|
||||
}
|
||||
result.Append(footer);
|
||||
await ctx.Channel.SendMessageAsync(result.ToString()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static byte[,] GenerateField(int width, int height, in int mineCount, in Random rng)
|
||||
{
|
||||
var len = width * height;
|
||||
var cells = new byte[len];
|
||||
// put mines
|
||||
for (var i = 0; i < mineCount; i++)
|
||||
cells[i] = (byte)CellVal.Mine;
|
||||
|
||||
//shuffle the board
|
||||
for (var i = 0; i < len - 1; i++)
|
||||
{
|
||||
var j = rng.Next(i, len);
|
||||
(cells[i], cells[j]) = (cells[j], cells[i]);
|
||||
}
|
||||
var result = new byte[height, width];
|
||||
Buffer.BlockCopy(cells, 0, result, 0, len);
|
||||
|
||||
//update mine indicators
|
||||
byte get(int x, int y) => x < 0 || x >= width || y < 0 || y >= height ? (byte)0 : result[y, x];
|
||||
|
||||
byte countMines(int x, int y)
|
||||
{
|
||||
byte c = 0;
|
||||
for (var yy = y - 1; yy <= y + 1; yy++)
|
||||
for (var xx = x - 1; xx <= x + 1; xx++)
|
||||
if ((CellVal)get(xx, yy) == CellVal.Mine)
|
||||
c++;
|
||||
return c;
|
||||
}
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++)
|
||||
if ((CellVal)result[y, x] != CellVal.Mine)
|
||||
result[y, x] = countMines(x, y);
|
||||
return result;
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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!;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user